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