mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 12:41:06 -04:00
Library drawings travel as a single-drawing, zero-plate .nest archive so the server stores opaque bytes and the client reuses NestWriter/NestReader. A characterization test first proves that shape round-trips the program, hole programs, source entities and suppression, offset, material, bends and units. Packages are canonical: fixed archive and nest timestamps, zero nest quantity and no best-fit caches. The same drawing content therefore has the same bytes and SHA-256, which later revision checks rely on to tell geometry changes from detail-only edits. Read checks the archive's own declaration before the lenient nest reader, which would otherwise default unknown units and drop empty plates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
7.3 KiB
C#
175 lines
7.3 KiB
C#
using System.IO.Compression;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.IO.Tests;
|
|
|
|
public class DrawingPackageTests
|
|
{
|
|
[Fact]
|
|
public void Package_RoundTripsTheDrawingAndUnits()
|
|
{
|
|
var drawing = DrawingPackageFixture.Build();
|
|
|
|
var contents = DrawingPackage.Read(DrawingPackage.Write(drawing, Units.Millimeters));
|
|
|
|
Assert.Empty(contents.Warnings);
|
|
Assert.Equal(Units.Millimeters, contents.Units);
|
|
var copy = contents.Drawing;
|
|
Assert.NotSame(drawing, copy);
|
|
Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(copy.Program));
|
|
Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(copy.Program));
|
|
Assert.Equal(drawing.Name, copy.Name);
|
|
Assert.Equal(drawing.Customer, copy.Customer);
|
|
Assert.Equal((drawing.Material.Name, drawing.Material.Grade, drawing.Material.Density),
|
|
(copy.Material.Name, copy.Material.Grade, copy.Material.Density));
|
|
Assert.Equal(drawing.Source.Offset, copy.Source.Offset);
|
|
Assert.Equal(drawing.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)),
|
|
copy.SourceEntities.Select(e => (e.Id, e.Type, e.Layer?.Name)));
|
|
Assert.True(drawing.SuppressedEntityIds.SetEquals(copy.SuppressedEntityIds));
|
|
var bend = Assert.Single(copy.Bends);
|
|
Assert.Equal((new Vector(0, 3), new Vector(10, 3), "UP 90"), (bend.StartPoint, bend.EndPoint, bend.NoteText));
|
|
// A library drawing has no nest demand of its own.
|
|
Assert.Equal(0, copy.Quantity.Required);
|
|
}
|
|
|
|
[Fact]
|
|
public void Package_DoesNotChangeTheDrawing()
|
|
{
|
|
var drawing = DrawingPackageFixture.Build();
|
|
var program = NestWriter.GetProgramText(drawing.Program);
|
|
|
|
DrawingPackage.Write(drawing, Units.Inches);
|
|
|
|
Assert.Equal(12, drawing.Quantity.Required);
|
|
Assert.Equal(program, NestWriter.GetProgramText(drawing.Program));
|
|
Assert.Equal("Synthetic bracket", drawing.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void Package_IsCanonical_SameContentSameBytes()
|
|
{
|
|
// Source entity ids are content, so the same drawing instance is written twice.
|
|
var drawing = DrawingPackageFixture.Build();
|
|
var first = DrawingPackage.Write(drawing, Units.Inches);
|
|
drawing.Quantity.Required = 40;
|
|
Thread.Sleep(2100); // ZIP times have two-second resolution; prove they are not recorded.
|
|
|
|
var second = DrawingPackage.Write(drawing, Units.Inches);
|
|
|
|
Assert.Equal(Sha(first), Sha(second));
|
|
Assert.Equal(first, DrawingPackage.Write(DrawingPackage.Read(first).Drawing, Units.Inches));
|
|
using var archive = new ZipArchive(new MemoryStream(first));
|
|
Assert.All(archive.Entries, e => Assert.Equal(new DateTime(1980, 1, 1), e.LastWriteTime.DateTime));
|
|
Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("bestfits/", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Package_ChangedContentOrUnits_ChangesBytes()
|
|
{
|
|
var drawing = DrawingPackageFixture.Build();
|
|
var baseline = Sha(DrawingPackage.Write(drawing, Units.Inches));
|
|
|
|
Assert.NotEqual(baseline, Sha(DrawingPackage.Write(drawing, Units.Millimeters)));
|
|
drawing.Customer = "Other customer";
|
|
var renamed = Sha(DrawingPackage.Write(drawing, Units.Inches));
|
|
Assert.NotEqual(baseline, renamed);
|
|
drawing.Bends[0].EndPoint = new Vector(10, 3.5);
|
|
Assert.NotEqual(renamed, Sha(DrawingPackage.Write(drawing, Units.Inches)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Write_RejectsUndefinedUnits()
|
|
{
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => DrawingPackage.Write(DrawingPackageFixture.Build(), (Units)9));
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_RefusesNestsThatAreNotOneDrawingWithoutPlates()
|
|
{
|
|
var empty = new Nest("empty");
|
|
var two = new Nest("two");
|
|
two.Drawings.Add(DrawingPackageFixture.Build());
|
|
var second = DrawingPackageFixture.Build();
|
|
second.Name = "Second";
|
|
two.Drawings.Add(second);
|
|
// The writer omits an empty plate, so the plate carries a part.
|
|
var withPlate = new Nest("plate");
|
|
var placed = DrawingPackageFixture.Build();
|
|
withPlate.Drawings.Add(placed);
|
|
var plate = new Plate(48, 96);
|
|
plate.Parts.Add(new Part(placed));
|
|
withPlate.Plates.Add(plate);
|
|
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Archive(empty)));
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Archive(two)));
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Archive(withPlate)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Feet")]
|
|
[InlineData("1")]
|
|
[InlineData("Inches,Millimeters")]
|
|
[InlineData("")]
|
|
public void Read_RefusesUnsupportedUnits(string units)
|
|
{
|
|
var package = EditNestJson(DrawingPackage.Write(DrawingPackageFixture.Build(), Units.Inches),
|
|
root => root["units"] = units);
|
|
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(package));
|
|
}
|
|
|
|
[Fact]
|
|
public void Read_RefusesMalformedArchives()
|
|
{
|
|
var valid = DrawingPackage.Write(DrawingPackageFixture.Build(), Units.Inches);
|
|
var noNestJson = Rewrite(valid, name => name == "nest.json" ? null : name);
|
|
var badJson = Rewrite(valid, name => name, (name, bytes) => name == "nest.json" ? Encoding.UTF8.GetBytes("{") : bytes);
|
|
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(Encoding.UTF8.GetBytes("not a zip")));
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(noNestJson));
|
|
Assert.Throws<InvalidDataException>(() => DrawingPackage.Read(badJson));
|
|
}
|
|
|
|
private static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes));
|
|
|
|
private static byte[] Archive(Nest nest)
|
|
{
|
|
using var stream = new MemoryStream();
|
|
new NestWriter(nest).Write(stream);
|
|
return stream.ToArray();
|
|
}
|
|
|
|
private static byte[] EditNestJson(byte[] package, Action<JsonObject> edit) =>
|
|
Rewrite(package, name => name, (name, bytes) =>
|
|
{
|
|
if (name != "nest.json") return bytes;
|
|
var root = JsonNode.Parse(Encoding.UTF8.GetString(bytes))!.AsObject();
|
|
edit(root);
|
|
return Encoding.UTF8.GetBytes(root.ToJsonString());
|
|
});
|
|
|
|
private static byte[] Rewrite(byte[] package, Func<string, string?> rename, Func<string, byte[], byte[]>? transform = null)
|
|
{
|
|
using var source = new ZipArchive(new MemoryStream(package));
|
|
using var output = new MemoryStream();
|
|
using (var target = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
|
|
{
|
|
foreach (var entry in source.Entries)
|
|
{
|
|
var name = rename(entry.FullName);
|
|
if (name is null) continue;
|
|
using var input = entry.Open();
|
|
using var copy = new MemoryStream();
|
|
input.CopyTo(copy);
|
|
var bytes = transform is null ? copy.ToArray() : transform(entry.FullName, copy.ToArray());
|
|
using var stream = target.CreateEntry(name).Open();
|
|
stream.Write(bytes);
|
|
}
|
|
}
|
|
return output.ToArray();
|
|
}
|
|
}
|