Files
OpenNest/OpenNest.IO.Tests/DrawingPackageTests.cs
T
ajandClaude Opus 5.5 f009fab31d feat(ui): make color schemes app-wide and keep only hand-picked drawing colors
- Add Drawing.HasCustomColor, set when a color is picked in Drawing
  Properties or comes from a library record with a stored color.
- Persist it as customColor in .nest files (omitted when false) and keep it
  out of drawing packages; split pieces inherit it.
- Recolor non-custom drawings from the active palette when a nest opens and
  when the scheme changes; custom colors keep their palette slot.
- Library settings store a color only when it is custom.
- Document drawing colors in nest-file-format.md and nesting-view-colors.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:24:33 -04:00

190 lines
8.0 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.HasCustomColor = true;
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();
}
}