diff --git a/OpenNest.FrontEnd.Tests/RegexImportFailureTests.cs b/OpenNest.FrontEnd.Tests/RegexImportFailureTests.cs new file mode 100644 index 0000000..38c4405 --- /dev/null +++ b/OpenNest.FrontEnd.Tests/RegexImportFailureTests.cs @@ -0,0 +1,191 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using ACadSharp; +using ACadSharp.IO; +using CSMath; +using OpenNest.Api; +using OpenNest.Bending; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.IO.Bending; +using OpenNest.Mcp; +using OpenNest.Mcp.Tools; +using CadLayer = ACadSharp.Tables.Layer; +using CadLine = ACadSharp.Entities.Line; + +namespace OpenNest.FrontEnd.Tests; + +// Console redirection and both registries are process-static. Reuse the existing +// nonparallel front-end collection; these tests require a Windows runtime. +[Collection("FrontEndRegistry")] +public class RegexImportFailureTests : IDisposable +{ + private const string MarkerLayer = "TESTTIMEOUT"; + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-regex-frontends-" + Guid.NewGuid()); + private readonly MarkerDetector detector = new(); + + public RegexImportFailureTests() + { + Directory.CreateDirectory(directory); + BendDetectorRegistry.Register(detector); + } + + public void Dispose() + { + // No unregister API: retained registrations become inert, and active + // registrations only throw for the marker document, never ordinary imports. + detector.Active = false; + Directory.Delete(directory, true); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Console_TimeoutExitsOneWithoutOutputEvenAfterEarlierImport(bool earlierSuccess) + { + var bad = WriteDxf("marker.dxf", marker: true); + var hash = Hash(bad); + var output = Path.Combine(directory, "output.nest"); + var args = new List(); + if (earlierSuccess) + args.Add(WriteDxf("good.dxf", marker: false)); + args.AddRange(new[] { bad, "--size", "20x30", "--output", output, + "--repair-bends-mm", "2", "--cad-units", "inches" }); + var originalOut = System.Console.Out; + var originalError = System.Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + try + { + System.Console.SetOut(stdout); + System.Console.SetError(stderr); + Assert.Equal(1, RunConsole(args.ToArray())); + } + finally + { + System.Console.SetOut(originalOut); + System.Console.SetError(originalError); + } + + Assert.Equal(1, detector.CallCount); + Assert.Contains("Error: failed to import DXF", stderr.ToString()); + Assert.Contains(bad, stderr.ToString()); + if (earlierSuccess) + Assert.Contains("Imported: good", stdout.ToString()); + Assert.DoesNotContain("Bend repair", stdout.ToString()); + Assert.False(File.Exists(output)); + Assert.Equal(hash, Hash(bad)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Api_TimeoutRetainsInnerExceptionAndNeverSolvesOrReturnsResponse(bool earlierSuccess) + { + var bad = WriteDxf("marker.dxf", marker: true); + var hash = Hash(bad); + var engine = new CountingEngine(); + var engineName = "RegexImportFailure-" + Guid.NewGuid(); + NestingEngineRegistry.Register(engineName, "test", () => engine); + var parts = new List(); + if (earlierSuccess) + parts.Add(new NestRequestPart { Id = "good", DxfPath = WriteDxf("good.dxf", marker: false), Quantity = 1 }); + parts.Add(new NestRequestPart { Id = "bad", DxfPath = bad, Quantity = 1 }); + var request = new NestRequest + { + Parts = parts, + Plates = [new NestRequestPlate { Id = "stock", Size = new Size(20, 30), Quantity = 1 }], + Engine = engineName, + }; + NestResponse? response = null; + + var exception = await Assert.ThrowsAsync(async () => + response = await NestRunner.RunAsync(request)); + + Assert.IsType(exception.InnerException); + Assert.Same(detector.Timeout, exception.InnerException); + Assert.Contains(bad, exception.Message); + Assert.Equal(1, detector.CallCount); + Assert.Equal(0, engine.CallCount); + Assert.Null(response); + Assert.Equal(hash, Hash(bad)); + } + + [Fact] + public void Mcp_TimeoutReturnsErrorWithoutAddingDrawingOrChangingEarlierDrawing() + { + var good = WriteDxf("good.dxf", marker: false); + var bad = WriteDxf("marker.dxf", marker: true); + var hash = Hash(bad); + var session = new NestSession(); + var tools = new InputTools(session); + Assert.StartsWith("Imported drawing", tools.ImportDxf(good)); + var earlier = Assert.Single(session.Drawings); + var earlierProgram = earlier.Program; + var count = session.Drawings.Count; + + var result = tools.ImportDxf(bad); + + Assert.StartsWith("Error:", result); + Assert.Contains(bad, result); + Assert.Equal(count, session.Drawings.Count); + Assert.Same(earlier, Assert.Single(session.Drawings)); + Assert.Same(earlierProgram, earlier.Program); + Assert.Equal(1, detector.CallCount); + Assert.Equal(hash, Hash(bad)); + } + + private static int RunConsole(params string[] args) + { + var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")! + .GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!; + return (int)method.Invoke(null, new object[] { args })!; + } + + private string WriteDxf(string name, bool marker) + { + var document = new CadDocument(); + document.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches; + document.Entities.Add(new CadLine(new XYZ(0, 0, 0), new XYZ(2, 0, 0)) + { + Layer = new CadLayer(marker ? MarkerLayer : "0"), + }); + document.Entities.Add(new CadLine(new XYZ(2, 0, 0), new XYZ(2, 2, 0))); + document.Entities.Add(new CadLine(new XYZ(2, 2, 0), new XYZ(0, 2, 0))); + document.Entities.Add(new CadLine(new XYZ(0, 2, 0), new XYZ(0, 0, 0))); + var path = Path.Combine(directory, name); + DxfWriter.Write(path, document, false); + return path; + } + + private static byte[] Hash(string path) => SHA256.HashData(File.ReadAllBytes(path)); + + private sealed class MarkerDetector : IBendDetector + { + public string Name { get; } = "FrontEndRegexTimeout-" + Guid.NewGuid(); + public bool Active { get; set; } = true; + public int CallCount { get; private set; } + public RegexMatchTimeoutException Timeout { get; } = new("marker", "", TimeSpan.FromSeconds(1)); + + public List DetectBends(CadDocument document) + { + if (!Active || !document.Entities.Any(e => e.Layer?.Name == MarkerLayer)) + return new List(); + CallCount++; + throw Timeout; + } + } + + private sealed class CountingEngine : INestingEngine + { + public int CallCount { get; private set; } + + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + CallCount++; + throw new InvalidOperationException("Import failure must abort before solving."); + } + } +} diff --git a/OpenNest.IO/Bending/SolidWorksBendDetector.cs b/OpenNest.IO/Bending/SolidWorksBendDetector.cs index 3d66963..588eff1 100644 --- a/OpenNest.IO/Bending/SolidWorksBendDetector.cs +++ b/OpenNest.IO/Bending/SolidWorksBendDetector.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -17,17 +18,26 @@ namespace OpenNest.IO.Bending private static readonly Regex BendNoteRegex = new Regex( @"(?UP|DOWN|DN)\s+(?\d+(\.\d+)?)[^A-Z\d]*R\s*(?\d+(\.\d+)?)", - RegexOptions.Compiled | RegexOptions.IgnoreCase + RegexOptions.Compiled | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1) ); private static readonly Regex MTextFormatRegex = new Regex( @"\\[fHCTQWASpOoLlKk][^;]*;|\\P|[{}]|%%[dDpPcC]", - RegexOptions.Compiled + RegexOptions.Compiled, + TimeSpan.FromSeconds(1) ); private static readonly Regex UnicodeEscapeRegex = new Regex( @"\\U\+([0-9A-Fa-f]{4})", - RegexOptions.Compiled + RegexOptions.Compiled, + TimeSpan.FromSeconds(1) + ); + + private static readonly Regex WhitespaceRegex = new Regex( + @"\s+", + RegexOptions.None, + TimeSpan.FromSeconds(1) ); public List DetectBends(CadDocument document) @@ -238,7 +248,7 @@ namespace OpenNest.IO.Bending result = MTextFormatRegex.Replace(result, " "); // Collapse multiple spaces - return Regex.Replace(result.Trim(), @"\s+", " "); + return WhitespaceRegex.Replace(result.Trim(), " "); } private MText FindClosestBendNote(ACadSharp.Entities.Line bendLine, List notes) diff --git a/OpenNest.Tests/Bending/SolidWorksBendDetectorTests.cs b/OpenNest.Tests/Bending/SolidWorksBendDetectorTests.cs index f2873ed..df16541 100644 --- a/OpenNest.Tests/Bending/SolidWorksBendDetectorTests.cs +++ b/OpenNest.Tests/Bending/SolidWorksBendDetectorTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using System.Text.RegularExpressions; using ACadSharp.IO; using OpenNest.Bending; using OpenNest.IO.Bending; @@ -6,6 +8,50 @@ namespace OpenNest.Tests.Bending; public class SolidWorksBendDetectorTests { + [Theory] + [InlineData("BendNoteRegex")] + [InlineData("MTextFormatRegex")] + [InlineData("UnicodeEscapeRegex")] + [InlineData("WhitespaceRegex")] + public void DetectorRegex_HasExplicitTimeout(string fieldName) + { + var field = typeof(SolidWorksBendDetector).GetField(fieldName, + BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(field); + var regex = Assert.IsType(field.GetValue(null)); + Assert.Equal(TimeSpan.FromSeconds(1), regex.MatchTimeout); + } + + [Theory] + [InlineData("UP 90 R0.125", BendDirection.Up, 0.125, "UP 90 R0.125")] + [InlineData("DOWN 90 R0.313", BendDirection.Down, 0.313, "DOWN 90 R0.313")] + [InlineData("DN 90 SHARP", BendDirection.Down, 0.0, "DN 90 SHARP")] + [InlineData(@"{\fArial;UP\P90\U+00B0 R0.125}", BendDirection.Up, 0.125, "UP 90° R0.125")] + [InlineData("UP\t90%%d R0.125", BendDirection.Up, 0.125, "UP 90° R0.125")] + [InlineData(@"UP 90 R0.125 \fUnterminated", BendDirection.Up, 0.125, @"UP 90 R0.125 \fUnterminated")] + public void DetectBends_PreservesDirectionFormattingAndRadius(string text, + BendDirection direction, double radius, string expectedNote) + { + var document = new ACadSharp.CadDocument(); + document.Entities.Add(new ACadSharp.Entities.Line(new CSMath.XYZ(0, 0, 0), new CSMath.XYZ(10, 0, 0)) + { + Layer = new ACadSharp.Tables.Layer("BEND"), + LineType = new ACadSharp.Tables.LineType("CENTER"), + }); + document.Entities.Add(new ACadSharp.Entities.MText + { + Value = text, + InsertPoint = new CSMath.XYZ(5, 0, 0), + Height = 1, + }); + + var bend = Assert.Single(new SolidWorksBendDetector().DetectBends(document)); + Assert.Equal(direction, bend.Direction); + Assert.Equal(90.0, bend.Angle); + Assert.Equal(radius, bend.Radius); + Assert.Equal(expectedNote, bend.NoteText); + } + [Fact] public void SolidWorksDetector_IsRegistered() { diff --git a/OpenNest.Tests/IO/CadImporterTimeoutTests.cs b/OpenNest.Tests/IO/CadImporterTimeoutTests.cs new file mode 100644 index 0000000..af6da32 --- /dev/null +++ b/OpenNest.Tests/IO/CadImporterTimeoutTests.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using ACadSharp; +using ACadSharp.IO; +using CSMath; +using OpenNest.Bending; +using OpenNest.IO; +using OpenNest.IO.Bending; +using CadLayer = ACadSharp.Tables.Layer; +using CadLine = ACadSharp.Entities.Line; + +namespace OpenNest.Tests.IO; + +[CollectionDefinition("Bend detector timeout registry", DisableParallelization = true)] +public class BendDetectorTimeoutRegistryCollection { } + +[Collection("Bend detector timeout registry")] +public class CadImporterTimeoutTests : IDisposable +{ + private const string MarkerLayer = "TESTTIMEOUT"; + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-regex-" + Guid.NewGuid()); + private readonly List registered = new(); + + public CadImporterTimeoutTests() => Directory.CreateDirectory(directory); + + public void Dispose() + { + // Register has no unregister API. Disable every double after its test, and + // gate calls on the marker layer while active. Never mutate the private registry. + foreach (var detector in registered) + detector.Active = false; + Directory.Delete(directory, true); + } + + [Fact] + public void AutoDetect_OnTimeout_DoesNotFallThroughToSuccessfulDetector() + { + var document = Document(marker: true); + // The built-in detector is registered first; this marker is not a bend line. + Assert.Empty(new SolidWorksBendDetector().DetectBends(document)); + var throwing = Register(throws: true); + var fallback = Register(throws: false); + Assert.True(BendDetectorRegistry.Detectors.ToList().IndexOf(throwing) + < BendDetectorRegistry.Detectors.ToList().IndexOf(fallback)); + + var exception = Assert.Throws(() => BendDetectorRegistry.AutoDetect(document)); + + Assert.Same(throwing.Timeout, exception); + Assert.Equal(1, throwing.CallCount); + Assert.Equal(0, fallback.CallCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Import_OnTimeout_PublishesNoResultOrRepairReportsAndPreservesSource(bool repair) + { + var path = WriteDxf("marker.dxf", marker: true); + var originalHash = Hash(path); + var detector = Register(throws: true); + var options = new CadImportOptions + { + BendDetectorName = detector.Name, + BendRepair = repair ? RepairOptions() : null, + }; + var published = new List(); + var reports = new List(); + + var exception = Assert.Throws(() => + { + var result = CadImporter.Import(path, options); + // These publication steps must be unreachable, even for an empty result. + published.Add(result); + reports.AddRange(result.BendRepairReports); + }); + + Assert.Same(detector.Timeout, exception); + Assert.Equal(1, detector.CallCount); + Assert.Empty(published); + Assert.Empty(reports); + Assert.Equal(originalHash, Hash(path)); + // Detection precedes both repair and etch conversion in Import. No result + // or output escapes; do not inspect its private, disposable scratch geometry. + Assert.Equal(new[] { path }, Directory.GetFiles(directory)); + } + + [Fact] + public void ImportDrawing_AfterEarlierSuccess_AbortsBatchBeforePublication() + { + var good = WriteDxf("good.dxf", marker: false); + var bad = WriteDxf("marker.dxf", marker: true); + var hashes = new[] { Hash(good), Hash(bad) }; + var detector = Register(throws: true); + var staged = new List(); + var published = new List(); + + var exception = Assert.Throws(() => + { + foreach (var path in new[] { good, bad }) + staged.Add(CadImporter.ImportDrawing(path, new CadImportOptions + { + BendDetectorName = detector.Name, + BendRepair = RepairOptions(), + })); + published.AddRange(staged); + }); + + Assert.Same(detector.Timeout, exception); + var earlier = Assert.Single(staged); + Assert.Equal(good, earlier.Source.Path); + Assert.NotEmpty(earlier.Program.Codes); + // This first drawing is a discardable local, not a published batch result. + Assert.Empty(published); + Assert.Equal(1, detector.CallCount); + Assert.Equal(hashes, new[] { Hash(good), Hash(bad) }); + Assert.Equal(new[] { good, bad }.Order(), Directory.GetFiles(directory).Order()); + } + + private MarkerDetector Register(bool throws) + { + var detector = new MarkerDetector(throws); + BendDetectorRegistry.Register(detector); + registered.Add(detector); + return detector; + } + + private static BendRepairOptions RepairOptions() => new() + { + DrawingUnits = BendRepairUnits.Inches, + MaxEndpointMovementMillimeters = 2, + }; + + private string WriteDxf(string name, bool marker) + { + var path = Path.Combine(directory, name); + DxfWriter.Write(path, Document(marker), false); + return path; + } + + private static CadDocument Document(bool marker) + { + var document = new CadDocument(); + document.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches; + document.Entities.Add(new CadLine(new XYZ(0, 0, 0), new XYZ(2, 0, 0)) + { + Layer = new CadLayer(marker ? MarkerLayer : "0"), + }); + document.Entities.Add(new CadLine(new XYZ(2, 0, 0), new XYZ(2, 2, 0))); + document.Entities.Add(new CadLine(new XYZ(2, 2, 0), new XYZ(0, 2, 0))); + document.Entities.Add(new CadLine(new XYZ(0, 2, 0), new XYZ(0, 0, 0))); + return document; + } + + private static byte[] Hash(string path) => SHA256.HashData(File.ReadAllBytes(path)); + + private sealed class MarkerDetector(bool throws) : IBendDetector + { + public string Name { get; } = "RegexTimeout-" + Guid.NewGuid(); + public bool Active { get; set; } = true; + public int CallCount { get; private set; } + public RegexMatchTimeoutException Timeout { get; } = new("marker", "", TimeSpan.FromSeconds(1)); + + public List DetectBends(CadDocument document) + { + if (!Active || !document.Entities.Any(e => e.Layer?.Name == MarkerLayer)) + return new List(); + CallCount++; + if (throws) + throw Timeout; + return new List { new() }; + } + } +}