Files
OpenNest/OpenNest.IO.Tests/DrawingPackageTests.cs
T
ajandClaude Opus 5.5 5a374aa3f4 feat(library): content-addressed packages and editable nesting settings
Schema v5 stores each distinct drawing package once in drawing_packages,
keyed by SHA-256; revisions point at it, so details, settings, archive and
restore revisions add no blob. The v4 drawing tables are replaced outright
(no production library existed); nest tables are untouched.

Nesting settings (cut first, priority, rotation constraints, color) become
structured record fields (DrawingLibraryRecord.Settings), returned on every
read, filterable by cutFirst, editable through the metadata endpoint and
applied by LibraryDrawingInsert. Canonical packages now leave every
record-owned field at its default, so renaming or changing settings never
changes the package hash. Saving a nest drawing to the library carries its
settings into the record.

The desktop library browser gains a Cut first column and an Edit Settings
dialog that saves notes and settings as a new revision with the same package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:47:18 -04:00

189 lines
7.9 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));
// Record-owned fields are left at their defaults; the library record supplies them.
Assert.Equal(("", ""), (copy.Name, copy.Customer));
Assert.Equal(("", "", drawing.Material.Density), (copy.Material.Name, copy.Material.Grade, copy.Material.Density));
Assert.Equal((0, false), (copy.Priority, copy.CutFirst));
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.Bends[0].EndPoint = new Vector(10, 3.5);
Assert.NotEqual(baseline, Sha(DrawingPackage.Write(drawing, Units.Inches)));
}
[Fact]
public void Package_RecordOwnedFields_DoNotChangeTheHash()
{
var drawing = DrawingPackageFixture.Build();
var baseline = Sha(DrawingPackage.Write(drawing, Units.Inches));
drawing.Name = "Renamed bracket";
drawing.Customer = "Other customer";
drawing.Material = new Material("Other Steel") { Grade = "Grade B", Density = drawing.Material.Density };
drawing.Color = System.Drawing.Color.FromArgb(255, 1, 2, 3);
drawing.Priority = 9;
drawing.CutFirst = true;
drawing.Constraints = new NestConstraints { StepAngle = 0.5, StartAngle = 0.25, EndAngle = 3, Allow180Equivalent = true };
Assert.Equal(baseline, 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();
}
}