From 5a374aa3f410ab358757e5d458e491f9cab7b0d3 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 17 Oct 2026 22:47:18 -0400 Subject: [PATCH] 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 --- OpenNest.Data/DrawingLibraryEditSession.cs | 67 ++++ OpenNest.Data/DrawingLibraryQuery.cs | 3 + OpenNest.Data/DrawingLibraryRecord.cs | 6 + OpenNest.Data/DrawingLibraryRules.cs | 3 +- OpenNest.Data/DrawingLibrarySaveSession.cs | 13 +- OpenNest.Data/DrawingNestingSettings.cs | 79 +++++ OpenNest.Data/IDrawingLibraryStore.cs | 7 + OpenNest.Data/LibraryDrawingInsert.cs | 2 + OpenNest.Data/RemoteDrawingLibrary.cs | 2 + OpenNest.IO.Tests/DrawingPackageTests.cs | 30 +- OpenNest.IO/DrawingPackage.cs | 21 ++ .../DrawingLibraryDatabaseTests.cs | 40 ++- .../DrawingLibraryEndToEndTests.cs | 4 +- .../DrawingLibraryStorageTests.cs | 171 ++++++++++ OpenNest.Server/DrawingLibraryRequest.cs | 15 +- OpenNest.Server/NestDatabase.Drawings.cs | 184 +++++++---- OpenNest.Server/NestDatabase.cs | 6 +- .../Data/DrawingLibraryEditSessionTests.cs | 103 ++++++ .../Data/DrawingLibrarySaveSessionTests.cs | 45 ++- .../Data/LibraryDrawingInsertTests.cs | 35 ++ .../Data/RemoteDrawingLibraryTests.cs | 27 ++ .../Forms/DrawingLibraryFormTests.cs | 92 +++++- .../Forms/SaveToDrawingLibraryFormTests.cs | 5 + OpenNest/Forms/DrawingLibraryForm.Designer.cs | 37 ++- OpenNest/Forms/DrawingLibraryForm.cs | 42 ++- .../Forms/EditLibraryDrawingForm.Designer.cs | 299 ++++++++++++++++++ OpenNest/Forms/EditLibraryDrawingForm.cs | 147 +++++++++ OpenNest/Forms/EditNestForm.cs | 2 +- .../SaveToDrawingLibraryForm.Designer.cs | 34 +- OpenNest/Forms/SaveToDrawingLibraryForm.cs | 23 ++ docs/nest-file-format.md | 19 ++ docs/nest-storage.md | 57 +++- 32 files changed, 1502 insertions(+), 118 deletions(-) create mode 100644 OpenNest.Data/DrawingLibraryEditSession.cs create mode 100644 OpenNest.Data/DrawingNestingSettings.cs create mode 100644 OpenNest.Server.Tests/DrawingLibraryStorageTests.cs create mode 100644 OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs create mode 100644 OpenNest/Forms/EditLibraryDrawingForm.Designer.cs create mode 100644 OpenNest/Forms/EditLibraryDrawingForm.cs diff --git a/OpenNest.Data/DrawingLibraryEditSession.cs b/OpenNest.Data/DrawingLibraryEditSession.cs new file mode 100644 index 00000000..07b66ba8 --- /dev/null +++ b/OpenNest.Data/DrawingLibraryEditSession.cs @@ -0,0 +1,67 @@ +namespace OpenNest.Data; + +/// +/// Edits one listed library drawing's notes and nesting settings in place: the save is a +/// metadata-only revision guarded by the revision it was listed with, so the package (and +/// its hash) stays the same. A stale revision raises +/// and changes nothing. +/// +public sealed class DrawingLibraryEditSession +{ + private readonly IDrawingLibraryStore _library; + + public DrawingLibraryEditSession(IDrawingLibraryStore library, DrawingLibraryRecord listed, string savedBy) + { + _library = library ?? throw new ArgumentNullException(nameof(library)); + Listed = listed ?? throw new ArgumentNullException(nameof(listed)); + if (listed.Id == Guid.Empty || listed.Revision < 1) + throw new ArgumentException("Choose a listed library drawing to edit.", nameof(listed)); + if (listed.Archived) + throw new ArgumentException("An archived library drawing cannot be edited. Restore it first.", nameof(listed)); + SavedBy = savedBy ?? ""; + } + + /// The record as it was listed; its revision guards the save. + public DrawingLibraryRecord Listed { get; } + + public string SavedBy { get; } + + /// The record to save: the listed details with new notes and settings. + public DrawingLibraryRecord Build(string notes, DrawingNestingSettings settings) => new() + { + Id = Listed.Id, + Name = Listed.Name, + Customer = Listed.Customer, + MaterialName = Listed.MaterialName, + MaterialGrade = Listed.MaterialGrade, + Thickness = Listed.Thickness, + Units = Listed.Units, + CutType = Listed.CutType, + Notes = notes ?? "", + Settings = settings ?? throw new ArgumentNullException(nameof(settings)), + SavedBy = SavedBy, + }; + + /// The first rule the edit breaks, or null when it may be saved. + public string? GetError(string notes, DrawingNestingSettings settings) => + DrawingLibraryRules.GetMetadataError(Build(notes, settings)); + + /// True when saving would change nothing. + public bool IsUnchanged(string notes, DrawingNestingSettings settings) => + (notes ?? "") == Listed.Notes && Equals(settings, Listed.Settings); + + /// Saves the edit as the next revision; returns the stored record. + public async Task SaveAsync(string notes, DrawingNestingSettings settings, + CancellationToken cancellationToken = default) + { + var record = Build(notes, settings); + var error = DrawingLibraryRules.GetMetadataError(record); + if (error != null) + throw new ArgumentException(error, nameof(settings)); + var saved = await _library.UpdateMetadataAsync(Listed.Id, record, Listed.Revision, cancellationToken); + if (!string.Equals(saved.PackageSha256, Listed.PackageSha256, StringComparison.OrdinalIgnoreCase) + || !Equals(saved.Settings, record.Settings) || saved.Notes != record.Notes) + throw new IOException("The nest server stored a different library drawing than was edited. Reload the library."); + return saved; + } +} diff --git a/OpenNest.Data/DrawingLibraryQuery.cs b/OpenNest.Data/DrawingLibraryQuery.cs index 65503c5d..6006fc29 100644 --- a/OpenNest.Data/DrawingLibraryQuery.cs +++ b/OpenNest.Data/DrawingLibraryQuery.cs @@ -32,6 +32,9 @@ public sealed class DrawingLibraryQuery public CutType? CutType { get; init; } + /// Only drawings whose Cut first setting equals this; null means any. + public bool? CutFirst { get; init; } + public bool IncludeArchived { get; init; } /// Column to order by; ties are broken by id in the same direction. diff --git a/OpenNest.Data/DrawingLibraryRecord.cs b/OpenNest.Data/DrawingLibraryRecord.cs index ecc88a86..bd83bc5e 100644 --- a/OpenNest.Data/DrawingLibraryRecord.cs +++ b/OpenNest.Data/DrawingLibraryRecord.cs @@ -33,6 +33,12 @@ public sealed class DrawingLibraryRecord public string Notes { get; set; } = ""; + /// + /// Nesting settings applied when the drawing is added to a nest. Not part of the + /// package, so changing them adds a revision that reuses the same package. + /// + public DrawingNestingSettings Settings { get; set; } = new(); + /// Hidden from browsing by default; still readable by id. public bool Archived { get; set; } diff --git a/OpenNest.Data/DrawingLibraryRules.cs b/OpenNest.Data/DrawingLibraryRules.cs index c23a42d4..5aebd728 100644 --- a/OpenNest.Data/DrawingLibraryRules.cs +++ b/OpenNest.Data/DrawingLibraryRules.cs @@ -43,7 +43,8 @@ public static class DrawingLibraryRules ? "thickness must be a finite number, zero or greater." : null) ?? (!Enum.IsDefined(record.Units) ? "units must be a supported unit name." : null) - ?? (!Enum.IsDefined(record.CutType) ? "cutType must be Laser, Waterjet or Plasma." : null); + ?? (!Enum.IsDefined(record.CutType) ? "cutType must be Laser, Waterjet or Plasma." : null) + ?? (record.Settings is null ? "settings is required." : record.Settings.GetValidationError()); } /// The first package rule breaks, or null when it may be stored. diff --git a/OpenNest.Data/DrawingLibrarySaveSession.cs b/OpenNest.Data/DrawingLibrarySaveSession.cs index 1ac6f6fb..a2789a32 100644 --- a/OpenNest.Data/DrawingLibrarySaveSession.cs +++ b/OpenNest.Data/DrawingLibrarySaveSession.cs @@ -137,8 +137,9 @@ public sealed class DrawingLibrarySaveSession /// /// Library details to start from: the drawing's name, its customer (or the nest's), the /// nest's material and grade (or the drawing's when the nest has none), the nest's - /// thickness and units, and the linked revision's cut type and notes when the drawing - /// came from this server's library, otherwise . + /// thickness and units, the linked revision's cut type and notes when the drawing + /// came from this server's library, otherwise , and the + /// drawing's own nesting settings. /// public DrawingLibraryRecord Prefill(CutType lastCutType, string savedBy) { @@ -154,6 +155,7 @@ public sealed class DrawingLibrarySaveSession Units = Nest.Units, CutType = link?.CutType ?? (Enum.IsDefined(lastCutType) ? lastCutType : CutType.Laser), Notes = link?.Notes ?? "", + Settings = DrawingNestingSettings.FromDrawing(Drawing), SavedBy = savedBy ?? "", }; } @@ -351,6 +353,7 @@ public sealed class DrawingLibrarySaveSession Units = record.Units, CutType = record.CutType, Notes = record.Notes, + Settings = record.Settings, Archived = record.Archived, PackageSha256 = record.PackageSha256, PackageSize = record.PackageSize, @@ -391,7 +394,8 @@ public sealed class DrawingLibrarySaveSession && record.Name == Metadata.Name && record.Customer == Metadata.Customer && record.MaterialName == Metadata.MaterialName && record.MaterialGrade == Metadata.MaterialGrade && record.Thickness == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness) - && record.Units == Metadata.Units && record.CutType == Metadata.CutType && record.Notes == Metadata.Notes; + && record.Units == Metadata.Units && record.CutType == Metadata.CutType && record.Notes == Metadata.Notes + && Equals(record.Settings, Metadata.Settings); /// True when a new request asks for exactly this save again. public bool Matches(DrawingLibraryRecord metadata, byte[] package, DrawingLibraryRecord? revise) => @@ -400,6 +404,7 @@ public sealed class DrawingLibrarySaveSession && metadata.Name == Metadata.Name && metadata.Customer == Metadata.Customer && metadata.MaterialName == Metadata.MaterialName && metadata.MaterialGrade == Metadata.MaterialGrade && DrawingLibraryRules.NormalizeThickness(metadata.Thickness) == DrawingLibraryRules.NormalizeThickness(Metadata.Thickness) - && metadata.Units == Metadata.Units && metadata.CutType == Metadata.CutType && metadata.Notes == Metadata.Notes; + && metadata.Units == Metadata.Units && metadata.CutType == Metadata.CutType && metadata.Notes == Metadata.Notes + && Equals(metadata.Settings, Metadata.Settings); } } diff --git a/OpenNest.Data/DrawingNestingSettings.cs b/OpenNest.Data/DrawingNestingSettings.cs new file mode 100644 index 00000000..f1dcf7cc --- /dev/null +++ b/OpenNest.Data/DrawingNestingSettings.cs @@ -0,0 +1,79 @@ +using System.Drawing; +using OpenNest; + +namespace OpenNest.Data; + +/// +/// Per-drawing nesting settings a library record owns. They are structured server fields, +/// not package content: canonical packages leave them at their defaults, and adding a library +/// drawing to a nest applies these values. Editing them adds a revision without new geometry. +/// A record, so two settings are equal when every value is. +/// +public sealed record DrawingNestingSettings +{ + /// Automatic sequencing cuts this drawing's parts first (for example test squares). + public bool CutFirst { get; init; } + + public int Priority { get; init; } + + /// Rotation step in radians; zero means the engine's default. + public double StepAngle { get; init; } + + /// Rotation start angle in radians. + public double StartAngle { get; init; } + + /// Rotation end angle in radians. + public double EndAngle { get; init; } + + public bool Allow180Equivalent { get; init; } + + /// + /// Display color as a 32-bit ARGB value. A fully transparent value (alpha 0, the default) + /// means no stored color: the drawing gets the next part color when it is added. + /// + public int ColorArgb { get; init; } + + /// The settings currently has. + public static DrawingNestingSettings FromDrawing(Drawing drawing) + { + ArgumentNullException.ThrowIfNull(drawing); + var constraints = drawing.Constraints ?? new NestConstraints(); + return new DrawingNestingSettings + { + CutFirst = drawing.CutFirst, + Priority = drawing.Priority, + StepAngle = constraints.StepAngle, + StartAngle = constraints.StartAngle, + EndAngle = constraints.EndAngle, + Allow180Equivalent = constraints.Allow180Equivalent, + ColorArgb = drawing.Color.ToArgb(), + }; + } + + /// Writes these settings onto . + public void ApplyTo(Drawing drawing) + { + ArgumentNullException.ThrowIfNull(drawing); + drawing.CutFirst = CutFirst; + drawing.Priority = Priority; + drawing.Constraints = new NestConstraints + { + StepAngle = StepAngle, + StartAngle = StartAngle, + EndAngle = EndAngle, + Allow180Equivalent = Allow180Equivalent, + }; + var color = Color.FromArgb(ColorArgb); + drawing.Color = color.A == 0 ? Drawing.GetNextColor() : color; + } + + /// The first rule these settings break, or null when they may be saved. + public string? GetValidationError() + { + if (!double.IsFinite(StepAngle) || !double.IsFinite(StartAngle) || !double.IsFinite(EndAngle)) + return "settings angles must be finite numbers."; + // Ranges are not checked: nest drawings carry whatever their files hold, and + // saving one to the library must not be refused for it. + return StepAngle < 0 ? "settings.stepAngle must be zero or greater." : null; + } +} diff --git a/OpenNest.Data/IDrawingLibraryStore.cs b/OpenNest.Data/IDrawingLibraryStore.cs index 0814fac0..f300b299 100644 --- a/OpenNest.Data/IDrawingLibraryStore.cs +++ b/OpenNest.Data/IDrawingLibraryStore.cs @@ -20,4 +20,11 @@ public interface IDrawingLibraryStore : IDrawingLibrary /// Saves the next revision when is current. Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, int expectedRevision, CancellationToken cancellationToken = default); + + /// + /// Saves new details and nesting settings, keeping the package, as the next revision + /// when is current. + /// + Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, + CancellationToken cancellationToken = default); } diff --git a/OpenNest.Data/LibraryDrawingInsert.cs b/OpenNest.Data/LibraryDrawingInsert.cs index 808c56ef..c307299b 100644 --- a/OpenNest.Data/LibraryDrawingInsert.cs +++ b/OpenNest.Data/LibraryDrawingInsert.cs @@ -136,10 +136,12 @@ public sealed class LibraryDrawingInsert warnings.Add($"{label} differs from this nest: {string.Join("; ", differences)}. The drawing is " + "added, but its drawing cut pins are dropped and the nest's cutting settings apply."); + // The record owns these fields; whatever the package holds for them is ignored. var drawing = package.Drawing; drawing.Name = record.Name; drawing.Customer = record.Customer; drawing.Material = new Material(record.MaterialName, record.MaterialGrade, drawing.Material?.Density ?? 0); + (record.Settings ?? new DrawingNestingSettings()).ApplyTo(drawing); drawing.Quantity.Required = quantity; drawing.Library = link; // Pins were authored for the library material and thickness; elsewhere the nest's diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 7e764de8..213d02fd 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -272,6 +272,8 @@ public sealed class RemoteDrawingLibrary : IDrawingLibraryStore, IDisposable text.Append("&thickness=").Append(DrawingLibraryRules.NormalizeThickness(thickness).ToString("0.####", CultureInfo.InvariantCulture)); if (query.CutType is { } cutType) text.Append("&cutType=").Append(cutType.ToString()); + if (query.CutFirst is { } cutFirst) + text.Append("&cutFirst=").Append(cutFirst ? "true" : "false"); if (query.IncludeArchived) text.Append("&includeArchived=true"); return text.ToString(); diff --git a/OpenNest.IO.Tests/DrawingPackageTests.cs b/OpenNest.IO.Tests/DrawingPackageTests.cs index 28430835..a46e158d 100644 --- a/OpenNest.IO.Tests/DrawingPackageTests.cs +++ b/OpenNest.IO.Tests/DrawingPackageTests.cs @@ -21,10 +21,10 @@ public class DrawingPackageTests 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)); + // 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))); @@ -73,11 +73,25 @@ public class DrawingPackageTests 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))); + 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] diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs index 81c0e189..dd92a7e5 100644 --- a/OpenNest.IO/DrawingPackage.cs +++ b/OpenNest.IO/DrawingPackage.cs @@ -15,6 +15,14 @@ namespace OpenNest.IO /// Packages are canonical, so the same drawing content always has the same bytes and /// SHA-256: archive timestamps and nest dates are fixed, the nest-specific required /// quantity is zero, and derived best-fit caches and library links are left out. + /// + /// A package holds geometry and geometry-bound data only (program, source entities, + /// suppressed IDs, bends, cut pins, breakoffs, material density). Fields the library + /// record owns are written as their defaults: the nest and drawing names, customer, + /// material name and grade, color, priority, cut first and rotation constraints. + /// Renaming a library drawing or changing its nesting settings therefore never changes + /// its package hash; adding it to a nest applies the record's values instead. + /// /// public static class DrawingPackage { @@ -22,6 +30,9 @@ namespace OpenNest.IO private static readonly DateTimeOffset EntryTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); private const string FixedDate = "2000-01-01T00:00:00.0000000"; + // Removed so readers fall back to their defaults; the library record owns these values. + private static readonly string[] RecordOwnedDrawingFields = { "customer", "color", "priority", "cutFirst", "constraints" }; + /// Packages without changing it. public static byte[] Write(Drawing drawing, Units units) { @@ -120,6 +131,7 @@ namespace OpenNest.IO var root = JsonNode.Parse(json)!.AsObject(); root["dateCreated"] = FixedDate; root["dateLastModified"] = FixedDate; + root["name"] = ""; if (root["drawings"] is JsonArray drawings) { foreach (var drawing in drawings.OfType()) @@ -128,6 +140,15 @@ namespace OpenNest.IO quantity["required"] = 0; // A library link is nest-side provenance; the server's record describes the package. drawing.Remove("library"); + // Record-owned fields: the library record supplies them on insert. + drawing["name"] = ""; + foreach (var field in RecordOwnedDrawingFields) + drawing.Remove(field); + if (drawing["material"] is JsonObject material) + { + material["name"] = ""; + material["grade"] = ""; + } } } diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index e03075bd..87ea1aae 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -21,7 +21,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Assert.Equal(4L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -31,30 +31,56 @@ public sealed class DrawingLibraryDatabaseTests Assert.Equal(2, upgraded.List().Count); } + [Fact] + public void V4Upgrade_ReplacesTheDrawingTablesAndKeepsNests() + { + using var factory = new ServerFactory(); + CreateVersion3Database(factory.DatabasePath); + // The v4 layout: one column set with the package blob copied into every revision. + Execute(factory.DatabasePath, """ + CREATE TABLE drawings (id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, package BLOB NOT NULL, PRIMARY KEY (id)); + CREATE TABLE drawing_revisions (id TEXT NOT NULL, revision INTEGER NOT NULL, name TEXT NOT NULL, package BLOB NOT NULL); + INSERT INTO drawings VALUES ('00000000-0000-0000-0000-000000000001', 1, 'Old', x'504B'); + INSERT INTO drawing_revisions SELECT * FROM drawings; + PRAGMA user_version=4; + """); + var before = NestDigest(factory.DatabasePath); + + using var database = new NestDatabase(factory.DatabasePath, pooling: false); + + Assert.Equal(5L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(before, NestDigest(factory.DatabasePath)); + Assert.Empty(database.QueryDrawings(new DrawingLibraryQuery { IncludeArchived = true }).Items); + var record = Record(); + database.InsertDrawing(record.Id, record, Package()); + Assert.Equal(1L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM drawing_packages")); + } + [Fact] public void NewerSchema_IsRefused() { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=5"); + Execute(factory.DatabasePath, "PRAGMA user_version=6"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } [Fact] - public void FailedV4Migration_RollsBackAndCanRestart() + public void FailedDrawingMigration_RollsBackAndCanRestart() { using var factory = new ServerFactory(); CreateVersion3Database(factory.DatabasePath); var before = NestDigest(factory.DatabasePath); - Execute(factory.DatabasePath, "CREATE TABLE drawing_revisions (fixture TEXT)"); + // A table holding an index's name makes the drawing schema fail part-way through. + Execute(factory.DatabasePath, "CREATE TABLE ix_drawings_savedAt (fixture TEXT)"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); Assert.Equal(3L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name='drawings'")); Assert.Equal(before, NestDigest(factory.DatabasePath)); - Execute(factory.DatabasePath, "DROP TABLE drawing_revisions"); + Execute(factory.DatabasePath, "DROP TABLE ix_drawings_savedAt"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); Assert.Equal(2, database.List().Count); } @@ -176,7 +202,7 @@ public sealed class DrawingLibraryDatabaseTests } } - /// A real schema-v3 file: v4 is additive, so dropping its tables restores v3 exactly. + /// A real schema-v3 file: the drawing tables are additive, so dropping them restores v3 exactly. private static void CreateVersion3Database(string path) { using (var database = new NestDatabase(path, pooling: false)) @@ -191,7 +217,7 @@ public sealed class DrawingLibraryDatabaseTests database.SetDeleted(a.Id, false, 3); } - Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; PRAGMA user_version=3;"); + Execute(path, "DROP TABLE drawing_revisions; DROP TABLE drawings; DROP TABLE drawing_packages; PRAGMA user_version=3;"); } private static string NestDigest(string path) diff --git a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs index 21c4ff2d..66e8de16 100644 --- a/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryEndToEndTests.cs @@ -79,7 +79,9 @@ public sealed class DrawingLibraryEndToEndTests Assert.Empty(contents.Warnings); Assert.Equal(NestWriter.GetProgramText(drawing.Program), NestWriter.GetProgramText(contents.Drawing.Program)); Assert.Equal(NestWriter.GetSubProgramsText(drawing.Program), NestWriter.GetSubProgramsText(contents.Drawing.Program)); - Assert.Equal("Synthetic bracket", contents.Drawing.Name); + // The record owns the name; the canonical package leaves it blank. + Assert.Equal("Synthetic bracket", found.Name); + Assert.Equal("", contents.Drawing.Name); } [Fact] diff --git a/OpenNest.Server.Tests/DrawingLibraryStorageTests.cs b/OpenNest.Server.Tests/DrawingLibraryStorageTests.cs new file mode 100644 index 00000000..01eb5433 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryStorageTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Data.Sqlite; +using OpenNest.Data; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; + +namespace OpenNest.Server.Tests; + +/// Content-addressed packages and structured nesting settings (schema v5). +public sealed class DrawingLibraryStorageTests +{ + private static readonly DrawingNestingSettings TestSquare = new() + { + CutFirst = true, + Priority = 4, + StepAngle = System.Math.PI / 2, + StartAngle = 0, + EndAngle = System.Math.PI, + Allow180Equivalent = true, + ColorArgb = unchecked((int)0xFF336699), + }; + + [Fact] + public async Task IdenticalPackages_AreStoredOnce() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = Package("shared"); + + var first = await Upload(client, Record("First"), package); + var second = await Upload(client, Record("Second"), package); + + Assert.Equal(first.PackageSha256, second.PackageSha256); + Assert.Equal(1L, Count(factory, "drawing_packages")); + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{second.Id}/file")); + } + + [Fact] + public async Task MetadataSettingsAndArchiveRevisions_AddNoPackage() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var package = Package(); + await Upload(client, record, package); + + record.Notes = "Edited notes"; + var notes = await PutMetadata(client, record, 1); + record.Settings = TestSquare; + var settings = await PutMetadata(client, record, 2); + using (var archive = await client.SendAsync(Guarded(HttpMethod.Post, $"/api/drawings/{record.Id}/archive", "\"3\""))) + Assert.Equal(HttpStatusCode.OK, archive.StatusCode); + + Assert.Equal((2, 3), (notes.Revision, settings.Revision)); + Assert.Equal(1L, Count(factory, "drawing_packages")); + Assert.Equal(4L, Count(factory, "drawing_revisions")); + for (var revision = 1; revision <= 4; revision++) + Assert.Equal(package, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/{revision}/file")); + } + + [Fact] + public async Task NewGeometry_AddsAPackage_AndEarlierRevisionsKeepTheirs() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var original = Package("original"); + var changed = Package("changed"); + await Upload(client, record, original); + + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/file", "\"1\"", Body(record, changed))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2L, Count(factory, "drawing_packages")); + Assert.Equal(original, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/1/file")); + Assert.Equal(changed, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/revisions/2/file")); + Assert.Equal(changed, await client.GetByteArrayAsync($"/api/drawings/{record.Id}/file")); + } + + [Fact] + public async Task Settings_RoundTripThroughEveryRead() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + record.Settings = TestSquare; + + var saved = await Upload(client, record, Package()); + var current = await client.GetFromJsonAsync($"/api/drawings/{record.Id}", Options); + var listed = Assert.Single((await client.GetFromJsonAsync("/api/drawings/query", Options))!.Items); + var history = Assert.Single((await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options))!.Items); + + Assert.Equal(TestSquare, saved.Settings); + Assert.Equal(TestSquare, current!.Settings); + Assert.Equal(TestSquare, listed.Settings); + Assert.Equal(TestSquare, history.Settings); + } + + [Fact] + public async Task SettingsOnlyEdit_IsARevision_AndRepeatingItIsANoOp() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var record = Record(); + var saved = await Upload(client, record, Package()); + + record.Settings = TestSquare; + var edited = await PutMetadata(client, record, 1); + var repeated = await PutMetadata(client, record, 2); + + Assert.Equal(2, edited.Revision); + Assert.Equal(TestSquare, edited.Settings); + Assert.Equal(saved.PackageSha256, edited.PackageSha256); + Assert.Equal(2, repeated.Revision); + var history = (await client.GetFromJsonAsync($"/api/drawings/{record.Id}/revisions", Options))!; + Assert.Equal(new[] { true, false }, history.Items.Select(r => r.Settings.CutFirst)); + } + + [Fact] + public async Task Query_FiltersByCutFirst() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var square = Record("Test square"); + square.Settings = new DrawingNestingSettings { CutFirst = true }; + await Upload(client, square, Package("square")); + await Upload(client, Record("Bracket"), Package("bracket")); + + var cutFirst = (await client.GetFromJsonAsync("/api/drawings/query?cutFirst=true", Options))!; + var others = (await client.GetFromJsonAsync("/api/drawings/query?cutFirst=false", Options))!; + using var invalid = await client.GetAsync("/api/drawings/query?cutFirst=yes"); + + Assert.Equal("Test square", Assert.Single(cutFirst.Items).Name); + Assert.Equal("Bracket", Assert.Single(others.Items).Name); + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + } + + [Theory] + [InlineData("""{"name":"x","thickness":0,"units":"Inches","cutType":"Laser","settings":{"stepAngle":-1}}""")] + [InlineData("""{"name":"x","thickness":0,"units":"Inches","cutType":"Laser","settings":null}""")] + [InlineData("""{"name":"x","thickness":0,"units":"Inches","cutType":"Laser","settings":{"priority":"high"}}""")] + public async Task InvalidSettings_AreRejectedWithNoState(string metadata) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var id = Guid.NewGuid(); + + using var response = await client.PostAsync("/api/drawings", Body(metadata.Replace("{\"name\"", $"{{\"id\":\"{id}\",\"name\""), Package())); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(0L, Count(factory, "drawing_packages")); + Assert.Equal(0L, Count(factory, "drawings")); + } + + private static async Task PutMetadata(HttpClient client, DrawingLibraryRecord record, int expected) + { + using var response = await client.SendAsync(Guarded(HttpMethod.Put, $"/api/drawings/{record.Id}/metadata", $"\"{expected}\"", + JsonContent.Create(record, options: Options))); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(Options))!; + } + + private static long Count(ServerFactory factory, string table) + { + using var connection = new SqliteConnection($"Data Source={factory.DatabasePath};Pooling=False"); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {table}"; + return (long)command.ExecuteScalar()!; + } +} diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs index b5c634ad..9c1c476c 100644 --- a/OpenNest.Server/DrawingLibraryRequest.cs +++ b/OpenNest.Server/DrawingLibraryRequest.cs @@ -14,7 +14,7 @@ internal static class DrawingLibraryRequest { private static readonly string[] QueryKeys = { - "search", "customer", "materialName", "materialGrade", "thickness", "cutType", + "search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst", "includeArchived", "sort", "order", "offset", "limit", }; @@ -96,6 +96,18 @@ internal static class DrawingLibraryRequest return false; } + bool? cutFirst = null; + if (values.TryGetValue("cutFirst", out var cutFirstText)) + { + if (!bool.TryParse(cutFirstText.ToString(), out var parsed)) + { + error = "cutFirst must be true or false."; + return false; + } + + cutFirst = parsed; + } + var offset = 0; var limit = DrawingLibraryQuery.DefaultLimit; if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error)) @@ -109,6 +121,7 @@ internal static class DrawingLibraryRequest MaterialGrade = values["materialGrade"].ToString(), Thickness = thickness, CutType = cutType, + CutFirst = cutFirst, IncludeArchived = includeArchived, Sort = sort, Descending = descending, diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index 36a20d9b..15057981 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -6,53 +6,69 @@ using OpenNest.Data; namespace OpenNest.Server; /// -/// Drawing library storage: drawings holds each drawing's current revision and -/// drawing_revisions every immutable revision, both in the nest database file -/// and under the same monitor. Every change appends a revision in one transaction. +/// Drawing library storage. drawing_packages stores each distinct package once, keyed +/// by its SHA-256; drawing_revisions holds every immutable revision (details, nesting +/// settings, archived flag and the package hash it points at); drawings holds each +/// drawing's current revision for browsing. All share the nest database file and monitor. +/// Every change appends a revision in one transaction, and only a new package adds a blob. /// public sealed partial class NestDatabase { - // Schema v4 is additive: the nest tables are untouched. private const string DrawingColumnDefinitions = """ - id TEXT NOT NULL, - revision INTEGER NOT NULL, - name TEXT NOT NULL, - customer TEXT NOT NULL DEFAULT '', - nameKey TEXT NOT NULL, - customerKey TEXT NOT NULL DEFAULT '', - materialName TEXT NOT NULL DEFAULT '', - materialKey TEXT NOT NULL DEFAULT '', - materialGrade TEXT NOT NULL DEFAULT '', - gradeKey TEXT NOT NULL DEFAULT '', - thickness REAL NOT NULL, - units TEXT NOT NULL, - cutType TEXT NOT NULL, - notes TEXT NOT NULL DEFAULT '', - archived INTEGER NOT NULL DEFAULT 0, - packageSha256 TEXT NOT NULL, - packageSize INTEGER NOT NULL, - savedAt TEXT NOT NULL, - savedBy TEXT NOT NULL DEFAULT '', - package BLOB NOT NULL + id TEXT NOT NULL, + revision INTEGER NOT NULL, + name TEXT NOT NULL, + customer TEXT NOT NULL DEFAULT '', + nameKey TEXT NOT NULL, + customerKey TEXT NOT NULL DEFAULT '', + materialName TEXT NOT NULL DEFAULT '', + materialKey TEXT NOT NULL DEFAULT '', + materialGrade TEXT NOT NULL DEFAULT '', + gradeKey TEXT NOT NULL DEFAULT '', + thickness REAL NOT NULL, + units TEXT NOT NULL, + cutType TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + cutFirst INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + stepAngle REAL NOT NULL DEFAULT 0, + startAngle REAL NOT NULL DEFAULT 0, + endAngle REAL NOT NULL DEFAULT 0, + allow180Equivalent INTEGER NOT NULL DEFAULT 0, + colorArgb INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + packageSha256 TEXT NOT NULL REFERENCES drawing_packages(sha256), + packageSize INTEGER NOT NULL, + savedAt TEXT NOT NULL, + savedBy TEXT NOT NULL DEFAULT '' """; + // Schema v5 replaces the v4 drawing tables (which copied the blob into every revision). private const string DrawingLibrarySchema = $""" + DROP TABLE IF EXISTS drawings; + DROP TABLE IF EXISTS drawing_revisions; + DROP TABLE IF EXISTS drawing_packages; + CREATE TABLE drawing_packages ( + sha256 TEXT NOT NULL PRIMARY KEY, + size INTEGER NOT NULL, + package BLOB NOT NULL); + CREATE TABLE drawing_revisions ({DrawingColumnDefinitions}, PRIMARY KEY (id, revision)); CREATE TABLE drawings ({DrawingColumnDefinitions}, PRIMARY KEY (id)); - CREATE TABLE drawing_revisions ({DrawingColumnDefinitions}); - CREATE UNIQUE INDEX ix_drawing_revisions_key ON drawing_revisions(id, revision); CREATE INDEX ix_drawings_customer_name ON drawings(customerKey, nameKey); CREATE INDEX ix_drawings_material ON drawings(materialKey); CREATE INDEX ix_drawings_thickness ON drawings(thickness); + CREATE INDEX ix_drawings_cutFirst ON drawings(cutFirst); CREATE INDEX ix_drawings_savedAt ON drawings(savedAt DESC); - PRAGMA user_version=4; + PRAGMA user_version=5; """; private const string DrawingColumns = """ id, revision, name, customer, materialName, materialGrade, thickness, units, - cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy + cutType, notes, archived, packageSha256, packageSize, savedAt, savedBy, + cutFirst, priority, stepAngle, startAngle, endAngle, allow180Equivalent, colorArgb """; - private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey, package"; + private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey"; // Case-insensitive (ASCII) substring; the text is always a bound, escaped LIKE parameter. private const string DrawingSearchPredicate = """ @@ -76,7 +92,10 @@ public sealed partial class NestDatabase lock (_sync) { using var command = _connection.CreateCommand(); - command.CommandText = "SELECT package FROM drawings WHERE id=$id"; + command.CommandText = """ + SELECT p.package FROM drawings d JOIN drawing_packages p ON p.sha256 = d.packageSha256 + WHERE d.id=$id + """; command.Parameters.AddWithValue("$id", id.ToString()); return command.ExecuteScalar() as byte[]; } @@ -98,6 +117,7 @@ public sealed partial class NestDatabase if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("gradeKey=$grade"); if (query.Thickness is not null) conditions.Add("thickness=$thickness"); if (query.CutType is not null) conditions.Add("cutType=$cutType"); + if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst"); var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions); lock (_sync) @@ -135,15 +155,17 @@ public sealed partial class NestDatabase using var transaction = _connection.BeginTransaction(); if (GetDrawing(id) is { } existing) throw new DrawingConflictException(existing, "A library drawing with this id already exists."); - WriteDrawingRevision(id, record, package, 1, archived: false, insert: true, transaction); + var sha = StorePackage(package, transaction); + WriteDrawingRevision(id, record, sha, package.LongLength, 1, archived: false, transaction); transaction.Commit(); return GetDrawing(id) ?? throw new InvalidOperationException("Insert did not persist."); } } /// - /// Appends a revision with new metadata and, when is not null, - /// new geometry. Returns null for an unknown id; an identical save is a no-op. + /// Appends a revision with new details and settings and, when + /// is not null, new geometry; otherwise the revision points at the current package and + /// stores no blob. Returns null for an unknown id; an identical save is a no-op. /// public DrawingLibraryRecord? UpdateDrawing(Guid id, DrawingLibraryRecord record, byte[]? package, int expectedRevision) { @@ -156,12 +178,19 @@ public sealed partial class NestDatabase throw new DrawingConflictException(current, "The library drawing has changed. Reload before saving."); if (current.Archived) throw new DrawingConflictException(current, "The library drawing is archived. Restore it before saving."); - package ??= GetDrawingPackage(id)!; - ValidateDrawing(record, package); - if (SameDrawingMetadata(current, record) && GetDrawingPackage(id)!.AsSpan().SequenceEqual(package)) + var error = DrawingLibraryRules.GetMetadataError(record) + ?? (package is null ? null : DrawingLibraryRules.GetPackageError(package)); + if (error is not null) + throw new ArgumentException(error, nameof(record)); + + var sha = package is null ? current.PackageSha256 : PackageSha256(package); + var size = package?.LongLength ?? current.PackageSize; + if (SameDrawingMetadata(current, record) && sha == current.PackageSha256) return current; - WriteDrawingRevision(id, record, package, checked(current.Revision + 1), archived: false, insert: false, transaction); + if (package is not null) + StorePackage(package, transaction); + WriteDrawingRevision(id, record, sha, size, checked(current.Revision + 1), archived: false, transaction); transaction.Commit(); return GetDrawing(id); } @@ -181,7 +210,8 @@ public sealed partial class NestDatabase throw new DrawingConflictException(current, archived ? "The library drawing is already archived." : "The library drawing is not archived."); - WriteDrawingRevision(id, current, GetDrawingPackage(id)!, checked(current.Revision + 1), archived, insert: false, transaction); + WriteDrawingRevision(id, current, current.PackageSha256, current.PackageSize, + checked(current.Revision + 1), archived, transaction); transaction.Commit(); return GetDrawing(id); } @@ -215,7 +245,10 @@ public sealed partial class NestDatabase lock (_sync) { using var command = _connection.CreateCommand(); - command.CommandText = "SELECT package FROM drawing_revisions WHERE id=$id AND revision=$revision"; + command.CommandText = """ + SELECT p.package FROM drawing_revisions r JOIN drawing_packages p ON p.sha256 = r.packageSha256 + WHERE r.id=$id AND r.revision=$revision + """; command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$revision", revision); return command.ExecuteScalar() as byte[]; @@ -229,32 +262,43 @@ public sealed partial class NestDatabase throw new ArgumentException(error, nameof(record)); } + private static string PackageSha256(byte[] package) => + Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant(); + private static bool SameDrawingMetadata(DrawingLibraryRecord a, DrawingLibraryRecord b) => a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName && a.MaterialGrade == b.MaterialGrade && a.Thickness == DrawingLibraryRules.NormalizeThickness(b.Thickness) && - a.Units == b.Units && a.CutType == b.CutType && a.Notes == b.Notes; + a.Units == b.Units && a.CutType == b.CutType && a.Notes == b.Notes && Equals(a.Settings, b.Settings); - // Called only under _sync with the caller's transaction; promotion and snapshot are one write. - private void WriteDrawingRevision(Guid id, DrawingLibraryRecord record, byte[] package, int revision, - bool archived, bool insert, SqliteTransaction transaction) + // Content-addressed: an identical package already stored is reused, never rewritten. + private string StorePackage(byte[] package, SqliteTransaction transaction) { + var sha = PackageSha256(package); using var command = _connection.CreateCommand(); command.Transaction = transaction; - command.CommandText = insert - ? $""" - INSERT INTO drawings ({DrawingStoredColumns}) - VALUES ($id, $revision, $name, $customer, $materialName, $materialGrade, $thickness, $units, - $cutType, $notes, $archived, $sha, $size, $savedAt, $savedBy, - $nameKey, $customerKey, $materialKey, $gradeKey, $package) - """ - : """ - UPDATE drawings SET revision=$revision, name=$name, customer=$customer, - materialName=$materialName, materialGrade=$materialGrade, thickness=$thickness, - units=$units, cutType=$cutType, notes=$notes, archived=$archived, packageSha256=$sha, - packageSize=$size, savedAt=$savedAt, savedBy=$savedBy, nameKey=$nameKey, - customerKey=$customerKey, materialKey=$materialKey, gradeKey=$gradeKey, package=$package - WHERE id=$id - """; + command.CommandText = "INSERT OR IGNORE INTO drawing_packages (sha256, size, package) VALUES ($sha, $size, $package)"; + command.Parameters.AddWithValue("$sha", sha); + command.Parameters.AddWithValue("$size", package.LongLength); + command.Parameters.AddWithValue("$package", package); + command.ExecuteNonQuery(); + return sha; + } + + // Called only under _sync with the caller's transaction; promotion and snapshot are one + // write. The package must already be stored under packageSha256. + private void WriteDrawingRevision(Guid id, DrawingLibraryRecord record, string packageSha256, long packageSize, + int revision, bool archived, SqliteTransaction transaction) + { + var settings = record.Settings ?? new DrawingNestingSettings(); + using var command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = $""" + INSERT OR REPLACE INTO drawings ({DrawingStoredColumns}) + VALUES ($id, $revision, $name, $customer, $materialName, $materialGrade, $thickness, $units, + $cutType, $notes, $archived, $sha, $size, $savedAt, $savedBy, + $cutFirst, $priority, $stepAngle, $startAngle, $endAngle, $allow180, $color, + $nameKey, $customerKey, $materialKey, $gradeKey) + """; command.Parameters.AddWithValue("$id", id.ToString()); command.Parameters.AddWithValue("$revision", revision); command.Parameters.AddWithValue("$name", record.Name); @@ -266,15 +310,21 @@ public sealed partial class NestDatabase command.Parameters.AddWithValue("$cutType", record.CutType.ToString()); command.Parameters.AddWithValue("$notes", record.Notes); command.Parameters.AddWithValue("$archived", archived ? 1 : 0); - command.Parameters.AddWithValue("$sha", Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant()); - command.Parameters.AddWithValue("$size", package.LongLength); + command.Parameters.AddWithValue("$sha", packageSha256); + command.Parameters.AddWithValue("$size", packageSize); command.Parameters.AddWithValue("$savedAt", DateTime.Now.ToString("o")); command.Parameters.AddWithValue("$savedBy", record.SavedBy); + command.Parameters.AddWithValue("$cutFirst", settings.CutFirst ? 1 : 0); + command.Parameters.AddWithValue("$priority", settings.Priority); + command.Parameters.AddWithValue("$stepAngle", settings.StepAngle); + command.Parameters.AddWithValue("$startAngle", settings.StartAngle); + command.Parameters.AddWithValue("$endAngle", settings.EndAngle); + command.Parameters.AddWithValue("$allow180", settings.Allow180Equivalent ? 1 : 0); + command.Parameters.AddWithValue("$color", settings.ColorArgb); command.Parameters.AddWithValue("$nameKey", SharedListNames.Key(record.Name)); command.Parameters.AddWithValue("$customerKey", SharedListNames.Key(record.Customer)); command.Parameters.AddWithValue("$materialKey", SharedListNames.Key(record.MaterialName)); command.Parameters.AddWithValue("$gradeKey", SharedListNames.Key(record.MaterialGrade)); - command.Parameters.AddWithValue("$package", package); command.ExecuteNonQuery(); command.Parameters.Clear(); @@ -296,6 +346,8 @@ public sealed partial class NestDatabase command.Parameters.AddWithValue("$thickness", DrawingLibraryRules.NormalizeThickness(thickness)); if (query.CutType is { } cutType) command.Parameters.AddWithValue("$cutType", cutType.ToString()); + if (query.CutFirst is { } cutFirst) + command.Parameters.AddWithValue("$cutFirst", cutFirst ? 1 : 0); } // Fixed allowlist: request text never reaches the ORDER BY clause. @@ -329,6 +381,16 @@ public sealed partial class NestDatabase PackageSize = reader.GetInt64(12), SavedAt = DateTime.Parse(reader.GetString(13)), SavedBy = reader.GetString(14), + Settings = new DrawingNestingSettings + { + CutFirst = reader.GetInt32(15) != 0, + Priority = reader.GetInt32(16), + StepAngle = reader.GetDouble(17), + StartAngle = reader.GetDouble(18), + EndAngle = reader.GetDouble(19), + Allow180Equivalent = reader.GetInt32(20) != 0, + ColorArgb = reader.GetInt32(21), + }, }; } diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index 25534646..d585beeb 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -316,7 +316,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 4) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 5) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -346,8 +346,10 @@ public sealed partial class NestDatabase : IDisposable """; command.ExecuteNonQuery(); } - if (version < 4) + if (version < 5) { + // v5 replaces the v4 drawing tables outright (no production library data + // existed); the nest tables are untouched. command.CommandText = DrawingLibrarySchema; command.ExecuteNonQuery(); } diff --git a/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs new file mode 100644 index 00000000..33ab7c92 --- /dev/null +++ b/OpenNest.Tests/Data/DrawingLibraryEditSessionTests.cs @@ -0,0 +1,103 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class DrawingLibraryEditSessionTests +{ + private static DrawingLibraryRecord Listed(bool archived = false) => new() + { + Id = Guid.NewGuid(), + Revision = 4, + Name = "Test square", + Customer = "Synthetic customer", + MaterialName = "Synthetic Steel", + MaterialGrade = "Grade A", + Thickness = 0.25, + Units = Units.Inches, + CutType = CutType.Laser, + Notes = "Calibration", + Archived = archived, + PackageSha256 = "abc123", + SavedBy = "Someone else", + }; + + [Fact] + public async Task SaveAsync_KeepsTheDetailsAndSendsNewSettingsAtTheListedRevision() + { + var listed = Listed(); + var store = new RecordingStore(); + var session = new DrawingLibraryEditSession(store, listed, "Synthetic operator"); + var settings = new DrawingNestingSettings { CutFirst = true }; + + Assert.True(session.IsUnchanged("Calibration", new DrawingNestingSettings())); + Assert.False(session.IsUnchanged("Calibration", settings)); + var saved = await session.SaveAsync("Calibration", settings); + + var (id, expected, record) = Assert.Single(store.Updates); + Assert.Equal((listed.Id, 4), (id, expected)); + Assert.Equal((listed.Name, listed.Customer, listed.MaterialName, listed.MaterialGrade, listed.Thickness, listed.CutType), + (record.Name, record.Customer, record.MaterialName, record.MaterialGrade, record.Thickness, record.CutType)); + Assert.Equal(("Calibration", "Synthetic operator"), (record.Notes, record.SavedBy)); + Assert.Equal(settings, record.Settings); + Assert.Equal(5, saved.Revision); + } + + [Fact] + public async Task SaveAsync_RefusesInvalidSettingsWithoutSending() + { + var store = new RecordingStore(); + var session = new DrawingLibraryEditSession(store, Listed(), ""); + var invalid = new DrawingNestingSettings { StepAngle = -1 }; + + Assert.NotNull(session.GetError("", invalid)); + await Assert.ThrowsAsync(() => session.SaveAsync("", invalid)); + Assert.Empty(store.Updates); + } + + [Fact] + public void ArchivedDrawings_CannotBeEdited() + { + Assert.Throws(() => new DrawingLibraryEditSession(new RecordingStore(), Listed(archived: true), "")); + } + + private sealed class RecordingStore : IDrawingLibraryStore + { + public List<(Guid Id, int Expected, DrawingLibraryRecord Record)> Updates { get; } = new(); + + public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, + CancellationToken cancellationToken = default) + { + Updates.Add((id, expectedRevision, record)); + return Task.FromResult(new DrawingLibraryRecord + { + Id = id, + Revision = expectedRevision + 1, + Name = record.Name, + Notes = record.Notes, + Settings = record.Settings, + PackageSha256 = "ABC123", + }); + } + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task GetAsync(Guid id, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task GetRevisionsAsync(Guid id, int offset = 0, + int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UploadAsync(DrawingLibraryRecord record, byte[] package, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + + public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package, + int expectedRevision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } +} diff --git a/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs index 974dc48a..c3b7ef76 100644 --- a/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingLibrarySaveSessionTests.cs @@ -58,6 +58,44 @@ public class DrawingLibrarySaveSessionTests Assert.Null(DrawingLibraryRules.GetMetadataError(metadata)); } + [Fact] + public void Prefill_TakesTheDrawingsNestingSettings() + { + var drawing = SyntheticDrawing(); + drawing.CutFirst = true; + drawing.Priority = 3; + drawing.Constraints = new NestConstraints { StepAngle = 0.5, StartAngle = 0, EndAngle = System.Math.PI, Allow180Equivalent = true }; + drawing.Color = System.Drawing.Color.FromArgb(255, 10, 20, 30); + + var metadata = Session(new FakeStore(), drawing).Prefill(CutType.Laser, ""); + + Assert.Equal(new DrawingNestingSettings + { + CutFirst = true, + Priority = 3, + StepAngle = 0.5, + EndAngle = System.Math.PI, + Allow180Equivalent = true, + ColorArgb = System.Drawing.Color.FromArgb(255, 10, 20, 30).ToArgb(), + }, metadata.Settings); + } + + [Fact] + public async Task SaveAsync_SettingsOnlyChange_RevisesWithTheSamePackage() + { + var store = new FakeStore(); + var existing = store.Seed(Metadata(), PackageA); + var session = Session(store); + + var result = await session.SaveAsync(Edit(Metadata(), r => { r.Settings = new DrawingNestingSettings { CutFirst = true }; }), + PackageA, existing); + + Assert.Equal(DrawingLibrarySaveOutcome.Revised, result.Outcome); + Assert.Equal(2, result.Saved.Revision); + Assert.True(result.Saved.Settings.CutFirst); + Assert.Equal(existing.PackageSha256, result.Saved.PackageSha256); + } + [Fact] public void Prefill_FallsBackToTheNestCustomerAndDrawingMaterial_AndUsesALinkOnThisServer() { @@ -363,6 +401,7 @@ public class DrawingLibrarySaveSessionTests Units = source.Units, CutType = source.CutType, Notes = source.Notes, + Settings = source.Settings, Archived = source.Archived, PackageSha256 = source.PackageSha256, PackageSize = source.PackageSize, @@ -484,6 +523,10 @@ public class DrawingLibrarySaveSessionTests return Reply(next); } + public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + private void FailBeforeStoring() { if (!FailNextRequest) @@ -519,7 +562,7 @@ public class DrawingLibrarySaveSessionTests private static bool SameDetails(DrawingLibraryRecord a, DrawingLibraryRecord b) => a.Name == b.Name && a.Customer == b.Customer && a.MaterialName == b.MaterialName && a.MaterialGrade == b.MaterialGrade && a.Thickness == b.Thickness && a.Units == b.Units - && a.CutType == b.CutType && a.Notes == b.Notes; + && a.CutType == b.CutType && a.Notes == b.Notes && Equals(a.Settings, b.Settings); private static bool Filter(string filter, string value) => string.IsNullOrWhiteSpace(filter) || SharedListNames.Key(filter) == SharedListNames.Key(value); diff --git a/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs index d3dac08c..edc2f362 100644 --- a/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs +++ b/OpenNest.Tests/Data/LibraryDrawingInsertTests.cs @@ -71,6 +71,41 @@ public class LibraryDrawingInsertTests link.Units, link.Notes, link.PackageSha256)); } + [Fact] + public void RecordSettings_ApplyOverWhateverThePackageHolds() + { + var nest = MatchingNest(); + var package = Package(); + package.Drawing.CutFirst = false; + package.Drawing.Priority = 7; + package.Drawing.Constraints = new NestConstraints { StepAngle = 1, StartAngle = 1, EndAngle = 2 }; + var record = Record(); + record.Settings = new DrawingNestingSettings + { + CutFirst = true, + Priority = 1, + StepAngle = System.Math.PI / 2, + EndAngle = System.Math.PI, + Allow180Equivalent = true, + ColorArgb = System.Drawing.Color.FromArgb(255, 40, 50, 60).ToArgb(), + }; + + LibraryDrawingInsert.Plan(nest, Server, record, package, 1).Apply(); + + var drawing = Assert.Single(nest.Drawings); + Assert.Equal(record.Settings, DrawingNestingSettings.FromDrawing(drawing)); + } + + [Fact] + public void RecordWithoutAColor_GetsAVisiblePartColor() + { + var nest = MatchingNest(); + + LibraryDrawingInsert.Plan(nest, Server, Record(), Package(), 1).Apply(); + + Assert.Equal(255, Assert.Single(nest.Drawings).Color.A); + } + [Fact] public void SpacingCaseAndThicknessRounding_StillMatch() { diff --git a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs index 9d92eab6..150930c7 100644 --- a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs +++ b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs @@ -165,6 +165,33 @@ public class RemoteDrawingLibraryTests Assert.Equal(record.Id, saved.Id); } + [Fact] + public async Task UpdateMetadataAsync_SendsAndReadsNestingSettings() + { + var record = SampleRecord(revision: 2); + record.Settings = new DrawingNestingSettings { CutFirst = true, Priority = 2, EndAngle = 1.5, ColorArgb = -16776961 }; + var handler = new StubHandler(_ => StubHandler.Json(record)); + using var library = Library(handler); + + var saved = await library.UpdateMetadataAsync(record.Id, record, 1); + + Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); + Assert.EndsWith($"/api/drawings/{record.Id}/metadata", handler.Requests[0].RequestUri!.AbsoluteUri); + Assert.Contains("\"settings\":{\"cutFirst\":true,\"priority\":2", handler.RequestBodies[0]); + Assert.Equal(record.Settings, saved.Settings); + } + + [Fact] + public async Task QueryAsync_SendsTheCutFirstFilter() + { + var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty(), total = 0 })); + using var library = Library(handler); + + await library.QueryAsync(new DrawingLibraryQuery { CutFirst = true }); + + Assert.Contains("&cutFirst=true", handler.Requests[0].RequestUri!.Query); + } + [Fact] public async Task UploadAsync_DifferentStoredPackageOrId_IsRefused() { diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs index cdd03574..5e48086d 100644 --- a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs +++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs @@ -121,7 +121,50 @@ public class DrawingLibraryFormTests "The drawing library refusal test did not complete."); } + [Fact] + public void EditSettings_SavesCutFirstAsANewRevision_AndTheAddAppliesIt() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + var record = library.Add("SYN-404", "Synthetic Steel", "Grade A", 0.25, Units.Inches, "Test square"); + var nest = MatchingNest(); + using var form = new DrawingLibraryForm(library, Server, nest); + form.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text); + form.EditDrawing = edit => + { + using var editor = new EditLibraryDrawingForm(edit); + editor.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text); + editor.Show(); + var save = Field