Files
OpenNest/OpenNest.Tests/Api/NestRunnerTests.cs
T

347 lines
12 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using OpenNest.Api;
using OpenNest.Converters;
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.Api;
public class NestRunnerTests
{
[Fact]
public async Task ExplicitPricesSelectCheaperStockAndSurviveArchive()
{
var path = CreateTempSquareDxf(4, 4);
var archive = Path.ChangeExtension(path, ".nestquote");
try
{
var response = await NestRunner.RunAsync(new NestRequest
{
Engine = "Rectangles",
Parts = [new NestRequestPart { DxfPath = path, Quantity = 1 }],
Plates = [new NestRequestPlate { Id = "small", Size = new Size(10, 10), Quantity = 1, Cost = 50 },
new NestRequestPlate { Id = "large", Size = new Size(12, 12), Quantity = 1, Cost = 7 }],
});
Assert.Equal(NestValidationStatus.Valid, response.ValidationStatus);
Assert.Equal("large", Assert.Single(response.PlateStockMappings).StockId);
Assert.Equal("supplied-cost", response.Costs.Basis);
Assert.Equal(7, response.Costs.GrossTotal);
Assert.Equal(7, response.Costs.NetScore);
Assert.Equal(0, response.Costs.SalvageCredit);
await response.SaveAsync(archive);
var reopened = await NestResponse.LoadAsync(archive);
Assert.Equal(7, reopened.Costs.GrossTotal);
Assert.Equal(new[] { 50d, 7d }, reopened.Nest.PlateOptions.Select(p => p.Cost));
Assert.Equal(7, reopened.Request.Plates[1].Cost);
}
finally
{
File.Delete(path);
File.Delete(archive);
}
}
[Fact]
public async Task MixedCostsFailBeforeImport()
{
var error = await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "missing.dxf", Quantity = 1 }],
Plates = [new NestRequestPlate { Id = "priced", Size = new Size(10, 10), Cost = 2 },
new NestRequestPlate { Id = "missing", Size = new Size(12, 12) }],
}));
Assert.Contains("Missing: missing", error.Message);
}
[Fact]
public async Task RunAsync_LegacySheetSize_UsesUnlimitedLegacyStockAndDerivedPartId()
{
var dxfPath = CreateTempSquareDxf(2, 2);
try
{
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = dxfPath, Quantity = 4 }],
SheetSize = new Size(10, 10),
Spacing = 0.1,
};
var response = await NestRunner.RunAsync(request);
Assert.Equal(NestJobStatus.Complete, response.Status);
Assert.Equal(NestJobStopReason.Completed, response.StopReason);
Assert.Equal("part-0", Assert.Single(response.Fulfillment).PartId);
Assert.Equal(4, response.Fulfillment[0].Placed);
var stock = Assert.Single(response.StockUsage);
Assert.Equal("legacy-sheet", stock.StockId);
Assert.Null(stock.Remaining);
Assert.All(
response.PlateStockMappings,
mapping => Assert.Equal("legacy-sheet", mapping.StockId)
);
Assert.Equal(response.SheetCount, response.PlateStockMappings.Count);
Assert.NotNull(response.Nest);
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "part-0");
Assert.True(response.Utilization > 0);
Assert.Equal(request, response.Request);
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_ExplicitMixedFinitePlates_UsesPhysicalStockEntries()
{
var dxfPath = CreateTempSquareDxf(4, 4);
try
{
var response = await NestRunner.RunAsync(
new NestRequest
{
Parts =
[
new NestRequestPart
{
Id = "square",
DxfPath = dxfPath,
Quantity = 5,
},
],
Plates =
[
new NestRequestPlate
{
Id = "small",
Size = new Size(5, 5),
Quantity = 1,
},
new NestRequestPlate
{
Id = "large",
Size = new Size(9, 9),
Quantity = 1,
},
],
}
);
Assert.Equal(NestJobStatus.Complete, response.Status);
Assert.Equal(2, response.SheetCount);
Assert.Equal(5, Assert.Single(response.Fulfillment).Placed);
Assert.Equal(0, response.Fulfillment[0].Unplaced);
Assert.Equal(
new[] { "large", "small" },
response.PlateStockMappings.Select(mapping => mapping.StockId).Order()
);
Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "small").Used);
Assert.Equal(1, response.StockUsage.Single(usage => usage.StockId == "large").Used);
Assert.All(response.StockUsage, usage => Assert.Equal(0, usage.Remaining));
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_ExplicitEmptyPlates_ReportsStockExhausted()
{
var dxfPath = CreateTempSquareDxf(2, 2);
try
{
var response = await NestRunner.RunAsync(
new NestRequest
{
Parts =
[
new NestRequestPart
{
Id = "square",
DxfPath = dxfPath,
Quantity = 1,
},
],
Plates = [],
}
);
Assert.Equal(NestJobStatus.Incomplete, response.Status);
Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason);
Assert.Equal(0, response.SheetCount);
Assert.Empty(response.StockUsage);
var fulfillment = Assert.Single(response.Fulfillment);
Assert.Equal(0, fulfillment.Placed);
Assert.Equal(1, fulfillment.Unplaced);
Assert.Empty(response.Nest.Plates);
Assert.Contains(response.Nest.Drawings, drawing => drawing.Name == "square");
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_FiniteStockExhaustion_PreservesUnplacedRequirementAndLockedRotation()
{
var dxfPath = CreateTempSquareDxf(4, 4);
try
{
var response = await NestRunner.RunAsync(
new NestRequest
{
Parts =
[
new NestRequestPart
{
Id = "locked-square",
DxfPath = dxfPath,
Quantity = 2,
AllowRotation = false,
},
],
Plates =
[
new NestRequestPlate
{
Id = "only-sheet",
Size = new Size(5, 5),
Quantity = 1,
},
],
}
);
Assert.Equal(NestJobStatus.Incomplete, response.Status);
Assert.Equal(NestJobStopReason.StockExhausted, response.StopReason);
var fulfillment = Assert.Single(response.Fulfillment);
Assert.Equal(2, fulfillment.Requested);
Assert.Equal(1, fulfillment.Placed);
Assert.Equal(1, fulfillment.Unplaced);
var stock = Assert.Single(response.StockUsage);
Assert.Equal(1, stock.Used);
Assert.Equal(0, stock.Remaining);
var drawing = Assert.Single(response.Nest.Drawings);
Assert.Equal("locked-square", drawing.Name);
Assert.Equal(OpenNest.Math.Angle.TwoPI, drawing.Constraints.StepAngle);
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_MixedPhysicalSheets_CalculatesWeightedUtilization()
{
var dxfPath = CreateTempSquareDxf(4, 4);
try
{
var response = await NestRunner.RunAsync(
new NestRequest
{
Parts =
[
new NestRequestPart
{
Id = "square",
DxfPath = dxfPath,
Quantity = 5,
},
],
Plates =
[
new NestRequestPlate
{
Id = "small",
Size = new Size(5, 5),
Quantity = 1,
},
new NestRequestPlate
{
Id = "large",
Size = new Size(9, 9),
Quantity = 1,
},
],
}
);
Assert.Equal(2, response.SheetCount);
Assert.Equal(80d / 106d, response.Utilization, precision: 6);
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_DuplicatePartIds_ThrowsBeforeNesting()
{
var dxfPath = CreateTempSquareDxf(2, 2);
try
{
var request = new NestRequest
{
Parts =
[
new NestRequestPart { Id = "duplicate", DxfPath = dxfPath },
new NestRequestPart { Id = "duplicate", DxfPath = dxfPath },
],
};
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
}
finally
{
File.Delete(dxfPath);
}
}
[Fact]
public async Task RunAsync_BadDxfPath_Throws()
{
var request = new NestRequest
{
Parts = [new NestRequestPart { DxfPath = "nonexistent.dxf", Quantity = 1 }],
};
await Assert.ThrowsAsync<FileNotFoundException>(() => NestRunner.RunAsync(request));
}
[Fact]
public async Task RunAsync_EmptyParts_Throws()
{
var request = new NestRequest { Parts = [] };
await Assert.ThrowsAsync<ArgumentException>(() => NestRunner.RunAsync(request));
}
private static string CreateTempSquareDxf(double width, double height)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0)));
shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, height)));
shape.Entities.Add(new Line(new Vector(width, height), new Vector(0, height)));
shape.Entities.Add(new Line(new Vector(0, height), new Vector(0, 0)));
var pgm = ConvertGeometry.ToProgram(shape);
var path = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.dxf");
Dxf.ExportProgram(pgm, path);
return path;
}
}