mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 00:21:06 -04:00
test(io): pin best-fit round trip through .nest archives
Characterize the bestfits/ entries before moving their mapping out of OpenNest.IO: golden bytes for hand-built sets, field-by-field restore of hand-built and computed sets (kept results and used plate sizes only), a nest without saved best fits, and the loaded-candidate frame on a rotated source drawing, which today rebuilds to a different pair than its saved bounds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e0699de7a
commit
063362a674
1 file changed
+310
@@ -0,0 +1,310 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
using OpenNest.Tests.BestFit;
|
||||
|
||||
namespace OpenNest.Tests.IO;
|
||||
|
||||
/// <summary>
|
||||
/// Pins how the best-fit pair cache round-trips through the <c>bestfits/</c> entries of a
|
||||
/// .nest archive: which sets are written, their exact bytes, and what a read puts back.
|
||||
/// </summary>
|
||||
[Collection(nameof(FillCacheCollection))]
|
||||
public class BestFitPersistenceTests
|
||||
{
|
||||
private const double Spacing = 0.25;
|
||||
private const double UsedWidth = 60;
|
||||
private const double UsedLength = 40;
|
||||
private const double UnusedWidth = 14;
|
||||
private const double UnusedLength = 30;
|
||||
|
||||
// Narrow enough that the filter drops some computed pairs (the T-shape pairs are ~8 x 17).
|
||||
private const double NarrowWidth = 8.1;
|
||||
|
||||
// Captured from the writer before the best-fit mapping moved out of OpenNest.IO.
|
||||
private const string GoldenBestFitJson = """
|
||||
[
|
||||
{
|
||||
"plateWidth": 60,
|
||||
"plateHeight": 40,
|
||||
"spacing": 0.25,
|
||||
"results": [
|
||||
{
|
||||
"part1Rotation": 0,
|
||||
"part2Rotation": 3.141592653589793,
|
||||
"part2OffsetX": 10.3,
|
||||
"part2OffsetY": -0.1,
|
||||
"strategyType": 2,
|
||||
"testNumber": 17,
|
||||
"candidateSpacing": 0.25,
|
||||
"rotatedArea": 123.456,
|
||||
"boundingWidth": 20.5,
|
||||
"boundingHeight": 6.0225,
|
||||
"optimalRotation": 0.7853981633974483,
|
||||
"keep": true,
|
||||
"reason": "",
|
||||
"trueArea": 100.125,
|
||||
"hullAngles": []
|
||||
},
|
||||
{
|
||||
"part1Rotation": 1.5707963267948966,
|
||||
"part2Rotation": 4.71238898038469,
|
||||
"part2OffsetX": 0,
|
||||
"part2OffsetY": 8.125,
|
||||
"strategyType": 0,
|
||||
"testNumber": 3,
|
||||
"candidateSpacing": 0.25,
|
||||
"rotatedArea": 98.5,
|
||||
"boundingWidth": 8.25,
|
||||
"boundingHeight": 11.9,
|
||||
"optimalRotation": 0,
|
||||
"keep": true,
|
||||
"reason": "Valid",
|
||||
"trueArea": 80,
|
||||
"hullAngles": [
|
||||
0,
|
||||
0.5235987755982988,
|
||||
-1E-12
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void Write_PopulatedCache_WritesGoldenBytesForUsedSizesAndKeptResultsOnly()
|
||||
{
|
||||
BestFitCache.Clear();
|
||||
var nest = CreateNest(MakeTShape());
|
||||
PopulateHandBuiltSets(nest.Drawings.First());
|
||||
|
||||
var entries = ReadEntries(Save(nest));
|
||||
|
||||
Assert.Equal(new[] { "bestfits/bestfit-1" }, entries.Keys.Where(IsBestFitEntry).ToArray());
|
||||
var expected = Encoding.UTF8.GetPreamble()
|
||||
.Concat(Encoding.UTF8.GetBytes(GoldenBestFitJson.ReplaceLineEndings(System.Environment.NewLine)))
|
||||
.ToArray();
|
||||
Assert.Equal(expected, entries["bestfits/bestfit-1"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_HandBuiltSets_RestoresKeptResultsForUsedSizeFieldByField()
|
||||
{
|
||||
BestFitCache.Clear();
|
||||
var nest = CreateNest(MakeTShape());
|
||||
var source = nest.Drawings.First();
|
||||
PopulateHandBuiltSets(source);
|
||||
var expected = BestFitCache.GetAllForDrawing(source)[(UsedWidth, UsedLength, Spacing)]
|
||||
.Where(r => r.Keep)
|
||||
.ToList();
|
||||
var bytes = Save(nest);
|
||||
|
||||
BestFitCache.Clear();
|
||||
var loaded = Load(bytes);
|
||||
var drawing = loaded.Drawings.Single();
|
||||
var restored = BestFitCache.GetAllForDrawing(drawing);
|
||||
|
||||
Assert.Equal(new[] { (UsedWidth, UsedLength, Spacing) }, restored.Keys.ToArray());
|
||||
AssertSameResults(expected, restored[(UsedWidth, UsedLength, Spacing)], drawing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_ComputedSets_RestoresUsedSizeOnlyAndKeptResultsOnly()
|
||||
{
|
||||
BestFitCache.Clear();
|
||||
var nest = CreateNest(MakeTShape(), NarrowWidth, UsedLength);
|
||||
var source = nest.Drawings.First();
|
||||
var used = BestFitCache.GetOrCompute(source, NarrowWidth, UsedLength, Spacing);
|
||||
BestFitCache.GetOrCompute(source, UnusedWidth, UnusedLength, Spacing);
|
||||
Assert.Contains(used, r => r.Keep);
|
||||
Assert.Contains(used, r => !r.Keep);
|
||||
var bytes = Save(nest);
|
||||
|
||||
BestFitCache.Clear();
|
||||
var drawing = Load(bytes).Drawings.Single();
|
||||
var restored = BestFitCache.GetAllForDrawing(drawing);
|
||||
|
||||
Assert.Equal(new[] { (NarrowWidth, UsedLength, Spacing) }, restored.Keys.ToArray());
|
||||
AssertSameResults(used.Where(r => r.Keep).ToList(), restored[(NarrowWidth, UsedLength, Spacing)], drawing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_RotatedSource_LoadedCandidatesHoldTheSourceDrawingNotACanonicalCopy()
|
||||
{
|
||||
// Pins current behaviour: computed candidates hold a canonical copy (angle 0), while
|
||||
// loaded candidates hold the loaded source drawing itself, so BuildCanonicalParts on a
|
||||
// loaded result builds from the source frame.
|
||||
BestFitCache.Clear();
|
||||
var nest = CreateNest(MakeRotatedTShape());
|
||||
var source = nest.Drawings.First();
|
||||
var computed = BestFitCache.GetOrCompute(source, UsedWidth, UsedLength, Spacing).First(r => r.Keep);
|
||||
Assert.NotSame(source, computed.Candidate.Drawing);
|
||||
Assert.Equal(0.0, computed.Candidate.Drawing.Source.Angle);
|
||||
var bytes = Save(nest);
|
||||
|
||||
BestFitCache.Clear();
|
||||
var drawing = Load(bytes).Drawings.Single();
|
||||
var loaded = BestFitCache.GetAllForDrawing(drawing)[(UsedWidth, UsedLength, Spacing)].First();
|
||||
|
||||
Assert.False(Tolerance.IsEqualTo(drawing.Source.Angle, 0));
|
||||
Assert.Equal(source.Source.Angle, drawing.Source.Angle, 9);
|
||||
Assert.Same(drawing, loaded.Candidate.Drawing);
|
||||
|
||||
Assert.Equal(0.0, computed.BuildCanonicalParts()[0].BaseDrawing.Source.Angle);
|
||||
Assert.Same(drawing, loaded.BuildCanonicalParts()[0].BaseDrawing);
|
||||
|
||||
// The computed pair rebuilds to its saved bounds; the loaded one, built from the
|
||||
// source frame with canonical-frame offsets, does not.
|
||||
var computedBounds = computed.GetCutBounds(computed.BuildSourceParts(source));
|
||||
Assert.Equal(computed.BoundingWidth, computedBounds.Length, 2);
|
||||
Assert.Equal(computed.BoundingHeight, computedBounds.Width, 2);
|
||||
var loadedBounds = loaded.GetCutBounds(loaded.BuildSourceParts(drawing));
|
||||
Assert.NotEqual(loaded.BoundingWidth * loaded.BoundingHeight, loadedBounds.Length * loadedBounds.Width, 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_NoBestFits_LoadsAndSavesUnchanged()
|
||||
{
|
||||
BestFitCache.Clear();
|
||||
var original = ReadEntries(Save(CreateNest(MakeTShape())));
|
||||
Assert.DoesNotContain(original.Keys, IsBestFitEntry);
|
||||
|
||||
BestFitCache.Clear();
|
||||
var loaded = Load(Save(CreateNest(MakeTShape())));
|
||||
Assert.Empty(BestFitCache.GetAllForDrawing(loaded.Drawings.Single()));
|
||||
var resaved = ReadEntries(Save(loaded));
|
||||
|
||||
Assert.Equal(original.Keys.OrderBy(k => k), resaved.Keys.OrderBy(k => k));
|
||||
foreach (var name in original.Keys.Where(k => k != "nest.json"))
|
||||
Assert.Equal(original[name], resaved[name]);
|
||||
}
|
||||
|
||||
private static bool IsBestFitEntry(string name) => name.StartsWith("bestfits/", System.StringComparison.Ordinal);
|
||||
|
||||
private static Drawing MakeTShape() => new TShape { Width = 10, Height = 8 }.GetDrawing();
|
||||
|
||||
private static Drawing MakeRotatedTShape()
|
||||
{
|
||||
var drawing = MakeTShape();
|
||||
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
|
||||
drawing.RecomputeCanonicalAngle();
|
||||
return drawing;
|
||||
}
|
||||
|
||||
private static Nest CreateNest(Drawing drawing, double width = UsedWidth, double length = UsedLength)
|
||||
{
|
||||
var nest = new Nest { Name = "BestFits" };
|
||||
nest.Drawings.Add(drawing);
|
||||
var plate = new Plate(width, length) { PartSpacing = Spacing };
|
||||
plate.Parts.Add(new Part(drawing, new Vector(1, 1)));
|
||||
nest.Plates.Add(plate);
|
||||
return nest;
|
||||
}
|
||||
|
||||
private static void PopulateHandBuiltSets(Drawing drawing)
|
||||
{
|
||||
BestFitCache.Populate(drawing, UsedWidth, UsedLength, Spacing, new()
|
||||
{
|
||||
MakeResult(drawing, 0, System.Math.PI, new Vector(0.1 + 0.2 + 10, -0.1), 2, 17,
|
||||
123.456, 20.5, 6.0225, System.Math.PI / 4, true, null, 100.125, null),
|
||||
MakeResult(drawing, 0, 0, new Vector(5, 5), 1, 9,
|
||||
50, 5, 10, 0, false, "Too wide", 40, new() { 1.0 }),
|
||||
MakeResult(drawing, System.Math.PI / 2, 3 * System.Math.PI / 2, new Vector(0, 8.125), 0, 3,
|
||||
98.5, 8.25, 11.9, 0, true, "Valid", 80, new() { 0.0, System.Math.PI / 6, -1e-12 }),
|
||||
});
|
||||
BestFitCache.Populate(drawing, UnusedWidth, UnusedLength, Spacing, new()
|
||||
{
|
||||
MakeResult(drawing, 0, System.Math.PI, new Vector(3, 4), 0, 1,
|
||||
10, 2, 5, 0, true, "Valid", 8, new()),
|
||||
});
|
||||
}
|
||||
|
||||
private static BestFitResult MakeResult(
|
||||
Drawing drawing, double rotation1, double rotation2, Vector offset, int strategy, int test,
|
||||
double rotatedArea, double width, double height, double optimal, bool keep, string reason,
|
||||
double trueArea, System.Collections.Generic.List<double> hullAngles) =>
|
||||
new()
|
||||
{
|
||||
Candidate = new PairCandidate
|
||||
{
|
||||
Drawing = drawing,
|
||||
Part1Rotation = rotation1,
|
||||
Part2Rotation = rotation2,
|
||||
Part2Offset = offset,
|
||||
StrategyIndex = strategy,
|
||||
TestNumber = test,
|
||||
Spacing = Spacing,
|
||||
},
|
||||
RotatedArea = rotatedArea,
|
||||
BoundingWidth = width,
|
||||
BoundingHeight = height,
|
||||
OptimalRotation = optimal,
|
||||
Keep = keep,
|
||||
Reason = reason,
|
||||
TrueArea = trueArea,
|
||||
HullAngles = hullAngles,
|
||||
};
|
||||
|
||||
private static void AssertSameResults(
|
||||
System.Collections.Generic.List<BestFitResult> expected,
|
||||
System.Collections.Generic.List<BestFitResult> actual,
|
||||
Drawing loadedDrawing)
|
||||
{
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
for (var i = 0; i < expected.Count; i++)
|
||||
{
|
||||
var e = expected[i];
|
||||
var a = actual[i];
|
||||
Assert.Same(loadedDrawing, a.Candidate.Drawing);
|
||||
AssertBits(e.Candidate.Part1Rotation, a.Candidate.Part1Rotation);
|
||||
AssertBits(e.Candidate.Part2Rotation, a.Candidate.Part2Rotation);
|
||||
AssertBits(e.Candidate.Part2Offset.X, a.Candidate.Part2Offset.X);
|
||||
AssertBits(e.Candidate.Part2Offset.Y, a.Candidate.Part2Offset.Y);
|
||||
Assert.Equal(e.Candidate.StrategyIndex, a.Candidate.StrategyIndex);
|
||||
Assert.Equal(e.Candidate.TestNumber, a.Candidate.TestNumber);
|
||||
AssertBits(e.Candidate.Spacing, a.Candidate.Spacing);
|
||||
AssertBits(e.RotatedArea, a.RotatedArea);
|
||||
AssertBits(e.BoundingWidth, a.BoundingWidth);
|
||||
AssertBits(e.BoundingHeight, a.BoundingHeight);
|
||||
AssertBits(e.OptimalRotation, a.OptimalRotation);
|
||||
Assert.Equal(e.Keep, a.Keep);
|
||||
Assert.Equal(e.Reason ?? "", a.Reason);
|
||||
AssertBits(e.TrueArea, a.TrueArea);
|
||||
Assert.Equal(
|
||||
(e.HullAngles ?? new()).Select(System.BitConverter.DoubleToInt64Bits),
|
||||
a.HullAngles.Select(System.BitConverter.DoubleToInt64Bits));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertBits(double expected, double actual) =>
|
||||
Assert.Equal(System.BitConverter.DoubleToInt64Bits(expected), System.BitConverter.DoubleToInt64Bits(actual));
|
||||
|
||||
private static byte[] Save(Nest nest)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
new NestWriter(nest).Write(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static Nest Load(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read();
|
||||
|
||||
private static System.Collections.Generic.Dictionary<string, byte[]> ReadEntries(byte[] bytes)
|
||||
{
|
||||
using var zip = new ZipArchive(new MemoryStream(bytes), ZipArchiveMode.Read);
|
||||
return zip.Entries.ToDictionary(e => e.FullName, e =>
|
||||
{
|
||||
using var entryStream = e.Open();
|
||||
using var copy = new MemoryStream();
|
||||
entryStream.CopyTo(copy);
|
||||
return copy.ToArray();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user