diff --git a/OpenNest.Data/DrawingGeometrySummary.cs b/OpenNest.Data/DrawingGeometrySummary.cs new file mode 100644 index 00000000..cd54be1e --- /dev/null +++ b/OpenNest.Data/DrawingGeometrySummary.cs @@ -0,0 +1,108 @@ +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Data; + +/// +/// The size and hole count of a library drawing's package, measured once per package by the +/// server worker (the server never parses packages) and used to filter the library. Lengths +/// are inches whatever units the drawing was drawn in. A package that could not be measured +/// keeps a summary with set so it is not measured again. +/// +public sealed record DrawingGeometrySummary +{ + /// Raised when the measurement changes, so the worker measures every package again. + public const int CurrentVersion = 1; + + public const int MaxErrorLength = 200; + + /// The that measured this package. + public int Version { get; init; } + + /// The longer side of the material's bounding box, in inches. + public double Length { get; init; } + + /// The shorter side of the material's bounding box, in inches. + public double Width { get; init; } + + /// Net material area (outline less holes), in square inches. + public double Area { get; init; } + + /// Closed contours inside the outline: holes and other cutouts. + public int Holes { get; init; } + + /// Why the package could not be measured; blank for a measured package. + public string Error { get; init; } = ""; + + public bool IsMeasured => Error.Length == 0; + + /// The first invalid field, or null when the summary may be stored. + public string? GetValidationError() + { + if (Version < 1) + return "version must be 1 or greater."; + if (Error is null) + return "error must be a string."; + if (Error.Length > MaxErrorLength) + return $"error must be at most {MaxErrorLength} characters."; + if (!double.IsFinite(Length) || !double.IsFinite(Width) || !double.IsFinite(Area)) + return "length, width and area must be finite."; + if (Length < 0 || Width < 0 || Area < 0 || Holes < 0) + return "length, width, area and holes must be zero or greater."; + if (Width > Length) + return "width must not exceed length."; + return null; + } + + /// Records why a package could not be measured. + public static DrawingGeometrySummary Failed(string error) + { + var text = string.IsNullOrWhiteSpace(error) ? "Could not be measured." : error.Trim(); + return new DrawingGeometrySummary + { + Version = CurrentVersion, + Error = text.Length > MaxErrorLength ? text[..MaxErrorLength] : text, + }; + } + + /// + /// Measures a drawing's material (marks such as etch and rapids excluded): the bounding box + /// of every closed contour, and the closed contours inside the largest one as holes. + /// + public static DrawingGeometrySummary Measure(Drawing drawing, Units units) + { + ArgumentNullException.ThrowIfNull(drawing); + if (drawing.Program is null) + return Failed("No program."); + + var material = ConvertProgram.ToGeometry(drawing.Program) + .Where(entity => SpecialLayers.IsMaterial(entity.Layer)); + var shapes = ShapeBuilder.GetShapes(material).Where(shape => shape.IsClosed()).ToList(); + if (shapes.Count == 0) + return Failed("No closed cut geometry."); + + var outline = shapes[0]; + foreach (var shape in shapes) + { + if (shape.BoundingBox.Area() > outline.BoundingBox.Area()) + outline = shape; + } + + var outer = outline.BoundingBox; + var holes = shapes.Where(shape => shape != outline && outer.Contains(shape.BoundingBox)).ToList(); + var bounds = shapes.Select(shape => shape.BoundingBox).ToList(); + var width = bounds.Max(b => b.Right) - bounds.Min(b => b.Left); + var height = bounds.Max(b => b.Top) - bounds.Min(b => b.Bottom); + var area = System.Math.Abs(outline.Area()) - holes.Sum(hole => System.Math.Abs(hole.Area())); + + var scale = units == Units.Millimeters ? 1 / 25.4 : 1.0; + return new DrawingGeometrySummary + { + Version = CurrentVersion, + Length = System.Math.Round(System.Math.Max(width, height) * scale, 4), + Width = System.Math.Round(System.Math.Min(width, height) * scale, 4), + Area = System.Math.Round(System.Math.Max(0, area) * scale * scale, 4), + Holes = holes.Count, + }; + } +} diff --git a/OpenNest.Data/DrawingLibraryBrowseSession.cs b/OpenNest.Data/DrawingLibraryBrowseSession.cs index 8d89785c..219f7e34 100644 --- a/OpenNest.Data/DrawingLibraryBrowseSession.cs +++ b/OpenNest.Data/DrawingLibraryBrowseSession.cs @@ -38,6 +38,9 @@ public sealed class DrawingLibraryBrowseSession : BrowseSessionTrue when the nest filter is lifted and every active drawing is listed. public bool ShowAll { get; private set; } + /// The Filter dialog's customer and ranges, applied with the nest filter; by default. + public DrawingLibraryFilter Filter { get; private set; } = DrawingLibraryFilter.None; + /// True when the latest request narrows by the nest's material, grade or thickness. public bool IsFilteredToNest => !ShowAll && (MaterialName.Length > 0 || MaterialGrade.Length > 0 || Thickness is not null); @@ -45,7 +48,8 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession public override string Summary => PageSummary("drawings", - Search.Length > 0 ? "No drawings match the search." + !Filter.IsEmpty ? (Search.Length > 0 ? "No drawings match the search and filter." : "No drawings match the filter.") + : Search.Length > 0 ? "No drawings match the search." : IsFilteredToNest ? "No library drawings match this nest's material and thickness. Tick Show all materials to see every drawing." : "No drawings in the library."); @@ -56,6 +60,17 @@ public sealed class DrawingLibraryBrowseSession : BrowseSessionApplies the Filter dialog's choices and returns to the first page. + public Task SetFilterAsync(DrawingLibraryFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + var error = filter.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(filter)); + Filter = filter; + return FirstPageAsync(); + } + protected override Task QueryAsync(CancellationToken cancellationToken) => _library.QueryAsync( new DrawingLibraryQuery @@ -64,6 +79,7 @@ public sealed class DrawingLibraryBrowseSession : BrowseSession +/// The drawing library's Filter dialog: a customer and inclusive ranges of thickness, size, +/// holes and saved date, all optional. Thickness and sizes are inches, whatever units each +/// drawing is in. Size and hole limits match only drawings the server has measured (see +/// ). The browse session sends it with every query. +/// +public sealed record DrawingLibraryFilter +{ + public static DrawingLibraryFilter None { get; } = new(); + + /// Exact customer, compared by . Blank means any. + public string Customer { get; init; } = ""; + + public double? MinThickness { get; init; } + + public double? MaxThickness { get; init; } + + /// + /// Whether drawings saved without a thickness (0, which suits any thickness) still match a + /// thickness range. False also leaves them out when no range is set. + /// + public bool IncludeBlankThickness { get; init; } = true; + + /// Limits on the longer side of the bounding box. + public double? MinLength { get; init; } + + public double? MaxLength { get; init; } + + /// Limits on the shorter side of the bounding box. + public double? MinWidth { get; init; } + + public double? MaxWidth { get; init; } + + public int? MinHoles { get; init; } + + public int? MaxHoles { get; init; } + + /// First and last day saved, inclusive, in the server's local dates. + public DateOnly? SavedFrom { get; init; } + + public DateOnly? SavedTo { get; init; } + + public bool IsEmpty => this == None; + + /// True when a size or hole limit is set, so only measured drawings can match. + public bool NeedsGeometry => + MinLength is not null || MaxLength is not null || MinWidth is not null || MaxWidth is not null + || MinHoles is not null || MaxHoles is not null; + + /// The first invalid range, or null when the filter may be sent. + public string? GetValidationError() => + RangeError("thickness", MinThickness, MaxThickness) + ?? RangeError("length", MinLength, MaxLength) + ?? RangeError("width", MinWidth, MaxWidth) + ?? RangeError("holes", MinHoles, MaxHoles) + ?? (SavedFrom is { } from && SavedTo is { } to && from > to ? "The saved-from date must not be after the saved-to date." : null) + ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength); + + /// A one-line summary for the library's filter label, for example "Thickness 0.0598-0.1875 in; Holes 2+". + public string Describe() + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(Customer)) + parts.Add($"Customer {Customer.Trim()}"); + if (Range(MinThickness, MaxThickness, " in") is { } thickness) + parts.Add($"Thickness {thickness}" + (IncludeBlankThickness ? " (or none)" : "")); + else if (!IncludeBlankThickness) + parts.Add("Has a thickness"); + if (Range(MinLength, MaxLength, " in") is { } length) + parts.Add($"Length {length}"); + if (Range(MinWidth, MaxWidth, " in") is { } width) + parts.Add($"Width {width}"); + if (Range(MinHoles, MaxHoles, "") is { } holes) + parts.Add($"Holes {holes}"); + if (SavedFrom is not null || SavedTo is not null) + parts.Add("Saved " + (SavedFrom, SavedTo) switch + { + ({ } from, { } to) => $"{Date(from)} to {Date(to)}", + ({ } from, null) => $"on or after {Date(from)}", + (null, { } to) => $"on or before {Date(to)}", + _ => "", + }); + return string.Join("; ", parts); + } + + internal static string? RangeError(string name, double? min, double? max) + { + if (min is { } low && (!double.IsFinite(low) || low < 0)) + return $"The lowest {name} must be a finite number, zero or greater."; + if (max is { } high && (!double.IsFinite(high) || high < 0)) + return $"The highest {name} must be a finite number, zero or greater."; + return min > max ? $"The lowest {name} must not be above the highest." : null; + } + + internal static string? RangeError(string name, int? min, int? max) + { + if (min < 0 || max < 0) + return $"The {name} limits must be zero or greater."; + return min > max ? $"The lowest {name} must not be above the highest." : null; + } + + private static string? Range(double? min, double? max, string unit) => (min, max) switch + { + ({ } low, { } high) when low == high => $"{Number(low)}{unit}", + ({ } low, { } high) => $"{Number(low)}-{Number(high)}{unit}", + ({ } low, null) => $"{Number(low)}{unit}+", + (null, { } high) => $"up to {Number(high)}{unit}", + _ => null, + }; + + private static string? Range(int? min, int? max, string unit) => + Range(min is { } low ? low : null, max is { } high ? high : (double?)null, unit); + + private static string Number(double value) => value.ToString("0.####", CultureInfo.CurrentCulture); + + private static string Date(DateOnly date) => date.ToString("d", CultureInfo.CurrentCulture); +} diff --git a/OpenNest.Data/DrawingLibraryQuery.cs b/OpenNest.Data/DrawingLibraryQuery.cs index 6006fc29..a3b1aca8 100644 --- a/OpenNest.Data/DrawingLibraryQuery.cs +++ b/OpenNest.Data/DrawingLibraryQuery.cs @@ -37,6 +37,16 @@ public sealed class DrawingLibraryQuery public bool IncludeArchived { get; init; } + /// + /// Thickness, size, hole and saved-date ranges; + /// means none. Its customer applies only when is blank. + /// + public DrawingLibraryFilter Filter { get; init; } = DrawingLibraryFilter.None; + + /// The customer matched: , or the filter's when that is blank. + public string EffectiveCustomer => + string.IsNullOrWhiteSpace(Customer) ? (Filter?.Customer ?? "") : Customer; + /// Column to order by; ties are broken by id in the same direction. public DrawingSortField Sort { get; init; } = DrawingSortField.SavedAt; @@ -59,6 +69,8 @@ public sealed class DrawingLibraryQuery return "cutType must be Laser, Waterjet or Plasma."; if (Thickness is { } thickness && (!double.IsFinite(thickness) || thickness < 0)) return "thickness must be a finite number, zero or greater."; + if (Filter is null) + return "filter must not be null."; if (Offset < 0) return "offset must be zero or greater."; if (Limit < 1 || Limit > MaxLimit) @@ -66,6 +78,7 @@ public sealed class DrawingLibraryQuery return DrawingLibraryRules.TextError("search", NormalizedSearch, MaxSearchLength) ?? DrawingLibraryRules.TextError("customer", Customer ?? "", DrawingLibraryRules.MaxTextLength) ?? DrawingLibraryRules.TextError("materialName", MaterialName ?? "", DrawingLibraryRules.MaxTextLength) - ?? DrawingLibraryRules.TextError("materialGrade", MaterialGrade ?? "", DrawingLibraryRules.MaxTextLength); + ?? DrawingLibraryRules.TextError("materialGrade", MaterialGrade ?? "", DrawingLibraryRules.MaxTextLength) + ?? Filter.GetValidationError(); } } diff --git a/OpenNest.Data/DrawingLibraryRecord.cs b/OpenNest.Data/DrawingLibraryRecord.cs index bd83bc5e..31fed42d 100644 --- a/OpenNest.Data/DrawingLibraryRecord.cs +++ b/OpenNest.Data/DrawingLibraryRecord.cs @@ -53,4 +53,10 @@ public sealed class DrawingLibraryRecord /// Who saved this revision, as reported by the client. public string SavedBy { get; set; } = ""; + + /// + /// The package's measured size and holes, returned by queries once the server worker has + /// measured it; null until then. Server-computed and ignored on upload. + /// + public DrawingGeometrySummary? Geometry { get; set; } } diff --git a/OpenNest.Data/DrawingSortField.cs b/OpenNest.Data/DrawingSortField.cs index c309827b..da8ef262 100644 --- a/OpenNest.Data/DrawingSortField.cs +++ b/OpenNest.Data/DrawingSortField.cs @@ -11,4 +11,10 @@ public enum DrawingSortField Thickness, CutType, Revision, + + /// The measured longer side; unmeasured drawings sort as smallest. + Length, + + /// The measured hole count; unmeasured drawings sort as fewest. + Holes, } diff --git a/OpenNest.Data/RemoteDrawingGeometry.cs b/OpenNest.Data/RemoteDrawingGeometry.cs new file mode 100644 index 00000000..f7b17cd7 --- /dev/null +++ b/OpenNest.Data/RemoteDrawingGeometry.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.RegularExpressions; + +namespace OpenNest.Data; + +/// +/// The server worker's side of library measurement (/api/drawing-packages): lists the +/// packages of current drawings not yet measured at a version, downloads one by its hash and +/// stores its . A server without these routes raises +/// ; other failures surface as . +/// +public sealed partial class RemoteDrawingGeometry : NestServerClient +{ + public const int MaxBatch = 500; + + public RemoteDrawingGeometry(string baseUrl) + : base(new HttpClient(), baseUrl, ownsClient: true) + { + } + + public RemoteDrawingGeometry(HttpClient httpClient, string baseUrl) + : base(httpClient, baseUrl, ownsClient: false) + { + } + + /// Up to package hashes with no summary at or later. + public async Task> GetUnmeasuredAsync(int version, int limit, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfLessThan(version, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1); + ArgumentOutOfRangeException.ThrowIfGreaterThan(limit, MaxBatch); + using var response = await _httpClient.GetAsync(Url( + $"api/drawing-packages/unmeasured?version={version.ToString(CultureInfo.InvariantCulture)}&limit={limit.ToString(CultureInfo.InvariantCulture)}"), + cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + throw new ServerTooOldException(); + await NestServerHttp.EnsureSuccessAsync(response, "list unmeasured library drawings", cancellationToken); + var hashes = await response.Content.ReadFromJsonAsync>(NestServerHttp.JsonOptions, cancellationToken) + ?? throw new IOException("Nest server returned an empty list of unmeasured drawings."); + if (hashes.Count > limit || hashes.Any(hash => hash is null || !IsSha256(hash))) + throw new IOException("Nest server returned an invalid list of unmeasured drawings."); + return hashes; + } + + /// The package stored under , or null when there is none. + public async Task GetPackageAsync(string sha256, CancellationToken cancellationToken = default) + { + ValidateSha(sha256); + using var response = await _httpClient.GetAsync(Url($"api/drawing-packages/{sha256}"), cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return null; + await NestServerHttp.EnsureSuccessAsync(response, $"download library package {sha256}", cancellationToken); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); + } + + /// Stores a package's summary; false when the server no longer has the package. + public async Task PutAsync(string sha256, DrawingGeometrySummary summary, CancellationToken cancellationToken = default) + { + ValidateSha(sha256); + ArgumentNullException.ThrowIfNull(summary); + var error = summary.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(summary)); + using var response = await _httpClient.PutAsJsonAsync( + Url($"api/drawing-packages/{sha256}/geometry"), summary, NestServerHttp.JsonOptions, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + return false; + await NestServerHttp.EnsureSuccessAsync(response, $"store the size of library package {sha256}", cancellationToken); + return true; + } + + /// True for a lower-case hex SHA-256, the form the server stores. + public static bool IsSha256(string text) => Sha256Pattern().IsMatch(text); + + private static void ValidateSha(string sha256) + { + if (sha256 is null || !IsSha256(sha256)) + throw new ArgumentException("A package hash is 64 lower-case hex characters.", nameof(sha256)); + } + + [GeneratedRegex("^[0-9a-f]{64}$")] + private static partial Regex Sha256Pattern(); +} diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 830605e4..b166f238 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -246,7 +246,7 @@ public sealed class RemoteDrawingLibrary : NestServerClient, IDrawingLibraryStor .Append("&order=").Append(query.Descending ? "desc" : "asc") .Append("&offset=").Append(query.Offset.ToString(CultureInfo.InvariantCulture)) .Append("&limit=").Append(query.Limit.ToString(CultureInfo.InvariantCulture)); - AppendFilter(text, "customer", query.Customer); + AppendFilter(text, "customer", query.EffectiveCustomer); AppendFilter(text, "materialName", query.MaterialName); AppendFilter(text, "materialGrade", query.MaterialGrade); if (query.Thickness is { } thickness) @@ -257,9 +257,35 @@ public sealed class RemoteDrawingLibrary : NestServerClient, IDrawingLibraryStor text.Append("&cutFirst=").Append(cutFirst ? "true" : "false"); if (query.IncludeArchived) text.Append("&includeArchived=true"); + AppendRanges(text, query.Filter); return text.ToString(); } + // Only the limits that are set, so an unfiltered query stays readable by older servers. + private static void AppendRanges(StringBuilder text, DrawingLibraryFilter filter) + { + AppendNumber(text, "minThickness", filter.MinThickness); + AppendNumber(text, "maxThickness", filter.MaxThickness); + if (!filter.IncludeBlankThickness) + text.Append("&includeBlankThickness=false"); + AppendNumber(text, "minLength", filter.MinLength); + AppendNumber(text, "maxLength", filter.MaxLength); + AppendNumber(text, "minWidth", filter.MinWidth); + AppendNumber(text, "maxWidth", filter.MaxWidth); + AppendNumber(text, "minHoles", filter.MinHoles); + AppendNumber(text, "maxHoles", filter.MaxHoles); + if (filter.SavedFrom is { } from) + text.Append("&savedFrom=").Append(from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + if (filter.SavedTo is { } to) + text.Append("&savedTo=").Append(to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + private static void AppendNumber(StringBuilder text, string key, double? value) + { + if (value is { } number) + text.Append('&').Append(key).Append('=').Append(number.ToString("R", CultureInfo.InvariantCulture)); + } + private static void AppendFilter(StringBuilder text, string key, string? value) { if (!string.IsNullOrWhiteSpace(value)) diff --git a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs index 01d1e96a..7619e706 100644 --- a/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingLibraryDatabaseTests.cs @@ -23,7 +23,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(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -44,7 +44,7 @@ public sealed class DrawingLibraryDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) { - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(before, NestDigest(factory.DatabasePath)); // Two distinct blobs across four revisions: the bracket's notes edit and the // plate both reuse the bracket's first package. @@ -81,7 +81,7 @@ public sealed class DrawingLibraryDatabaseTests { using var factory = new ServerFactory(); using (new NestDatabase(factory.DatabasePath, pooling: false)) { } - Execute(factory.DatabasePath, "PRAGMA user_version=19"); + Execute(factory.DatabasePath, "PRAGMA user_version=20"); Assert.Throws(() => new NestDatabase(factory.DatabasePath, pooling: false)); } @@ -106,7 +106,7 @@ public sealed class DrawingLibraryDatabaseTests """); using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(-3, upgraded.GetDrawing(record.Id)!.Settings.Priority); Assert.Equal(-3, Assert.Single(upgraded.DrawingHistory(record.Id, 0, 100)!.Items).Settings.Priority); Assert.Equal(int.MaxValue, upgraded.GetDrawing(other.Id)!.Settings.Priority); diff --git a/OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs b/OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs new file mode 100644 index 00000000..93fc2dd5 --- /dev/null +++ b/OpenNest.Server.Tests/DrawingLibraryRangeFilterTests.cs @@ -0,0 +1,256 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.Extensions.Logging.Abstractions; +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Server.Worker; +using static OpenNest.Server.Tests.DrawingLibraryFixtures; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Server.Tests; + +/// +/// The library's Filter dialog ranges: thickness in inches across units, measured size and +/// holes from the worker's package summaries, saved dates, and the measurement routes. +/// +public sealed class DrawingLibraryRangeFilterTests +{ + private static DrawingGeometrySummary Measured(double length, double width, int holes) => new() + { + Version = DrawingGeometrySummary.CurrentVersion, + Length = length, + Width = width, + Area = length * width, + Holes = holes, + }; + + // Name, thickness, units, and the summary stored for its package (null: not yet measured). + private static async Task Seed(HttpClient client) + { + var rows = new (string Name, double Thickness, Units Units, DrawingGeometrySummary? Geometry)[] + { + ("Gauge washer", 0.0598, Units.Inches, Measured(2, 2, 2)), + ("Plate tab", 0.1875, Units.Inches, Measured(3.5, 1, 3)), + ("Thick tab", 0.25, Units.Inches, Measured(3, 1, 2)), + ("Big gauge panel", 0.06, Units.Inches, Measured(12, 4, 8)), + ("Metric clip", 3, Units.Millimeters, Measured(3.9, 0.5, 2)), + ("No thickness", 0, Units.Inches, Measured(1, 1, 4)), + ("Unmeasured", 0.105, Units.Inches, null), + ("Unreadable", 0.105, Units.Inches, DrawingGeometrySummary.Failed("Synthetic failure")), + }; + foreach (var row in rows) + { + var record = Record(row.Name); + record.Thickness = row.Thickness; + record.Units = row.Units; + var package = Package(row.Name); + await Upload(client, record, package); + if (row.Geometry is not null) + { + using var put = await client.PutAsJsonAsync($"/api/drawing-packages/{Sha256(package)}/geometry", row.Geometry, Options); + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + } + } + } + + [Theory] + [InlineData("minThickness=0.0598&maxThickness=0.1875", "Big gauge panel|Gauge washer|Metric clip|No thickness|Plate tab|Unmeasured|Unreadable")] + [InlineData("minThickness=0.0598&maxThickness=0.1875&includeBlankThickness=false", "Big gauge panel|Gauge washer|Metric clip|Plate tab|Unmeasured|Unreadable")] + [InlineData("includeBlankThickness=false&maxThickness=0.1", "Big gauge panel|Gauge washer")] + [InlineData("minThickness=0.2&includeBlankThickness=false", "Thick tab")] + [InlineData("maxLength=4", "Gauge washer|Metric clip|No thickness|Plate tab|Thick tab")] + [InlineData("maxLength=4&minHoles=3", "No thickness|Plate tab")] + [InlineData("minWidth=1&maxWidth=2", "Gauge washer|No thickness|Plate tab|Thick tab")] + [InlineData("minHoles=0", "Big gauge panel|Gauge washer|Metric clip|No thickness|Plate tab|Thick tab")] + [InlineData("maxHoles=2&minLength=3", "Metric clip|Thick tab")] + [InlineData("minThickness=0.0598&maxThickness=0.1875&includeBlankThickness=false&maxLength=4&minHoles=2", "Gauge washer|Metric clip|Plate tab")] + [InlineData("savedFrom=2000-01-01", "Big gauge panel|Gauge washer|Metric clip|No thickness|Plate tab|Thick tab|Unmeasured|Unreadable")] + [InlineData("savedTo=2000-01-01", "")] + public async Task Ranges_MatchInclusivelyInInchesAndOnlyMeasuredSizes(string query, string expected) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await Seed(client); + + var page = await DrawingLibraryApiTests.Query(client, query + "&sort=name&order=asc"); + + Assert.Equal(expected, string.Join("|", page.Items.Select(r => r.Name))); + Assert.Equal(page.Items.Count, page.Total); + } + + [Fact] + public async Task Query_ReturnsEachDrawingsSummary_AndSortsByLengthAndHoles() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await Seed(client); + + var page = await DrawingLibraryApiTests.Query(client, "sort=length&order=desc"); + Assert.Equal("Big gauge panel", page.Items[0].Name); + Assert.Equal(new DrawingGeometrySummary { Version = 1, Length = 12, Width = 4, Area = 48, Holes = 8 }, page.Items[0].Geometry); + Assert.Null(page.Items.Single(r => r.Name == "Unmeasured").Geometry); + Assert.Equal("Synthetic failure", page.Items.Single(r => r.Name == "Unreadable").Geometry!.Error); + + var byHoles = await DrawingLibraryApiTests.Query(client, "sort=holes&order=desc&limit=1"); + Assert.Equal("Big gauge panel", Assert.Single(byHoles.Items).Name); + } + + [Fact] + public async Task SavedDates_CompareTheServersLocalDate() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + await Upload(client, Record("Today"), Package("today")); + var today = DateTime.Now.ToString("yyyy-MM-dd"); + var tomorrow = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd"); + + Assert.Equal(1, (await DrawingLibraryApiTests.Query(client, $"savedFrom={today}&savedTo={today}")).Total); + Assert.Equal(0, (await DrawingLibraryApiTests.Query(client, $"savedFrom={tomorrow}")).Total); + } + + [Theory] + [InlineData("minThickness=-1")] + [InlineData("maxLength=abc")] + [InlineData("minLength=5&maxLength=4")] + [InlineData("minHoles=-2")] + [InlineData("minHoles=1.5")] + [InlineData("savedFrom=11/12/2026")] + [InlineData("savedFrom=2026-02-01&savedTo=2026-01-01")] + [InlineData("includeBlankThickness=maybe")] + [InlineData("maxLength=NaN")] + public async Task InvalidRanges_AreRefused(string query) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var response = await client.GetAsync("/api/drawings/query?" + query); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Unmeasured_ListsEachCurrentPackageOnceUntilMeasuredAtTheVersion() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var shared = Package("shared"); + await Upload(client, Record("First"), shared); + await Upload(client, Record("Same geometry"), shared); + var other = Package("other"); + await Upload(client, Record("Other"), other); + var expected = new[] { Sha256(shared), Sha256(other) }.Order().ToList(); + + Assert.Equal(expected, await client.GetFromJsonAsync>("/api/drawing-packages/unmeasured?version=1&limit=10")); + + using (var put = await client.PutAsJsonAsync($"/api/drawing-packages/{Sha256(shared)}/geometry", Measured(2, 1, 0), Options)) + Assert.Equal(HttpStatusCode.NoContent, put.StatusCode); + Assert.Equal(new[] { Sha256(other) }, await client.GetFromJsonAsync>("/api/drawing-packages/unmeasured?version=1&limit=10")); + // A newer measurement version lists it again. + Assert.Equal(expected, await client.GetFromJsonAsync>("/api/drawing-packages/unmeasured?version=2&limit=10")); + Assert.Equal(shared, await client.GetByteArrayAsync($"/api/drawing-packages/{Sha256(shared)}")); + } + + [Fact] + public async Task OlderVersionSummary_NeverReplacesANewerOne() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = Package("versioned"); + await Upload(client, Record("Versioned"), package); + var url = $"/api/drawing-packages/{Sha256(package)}/geometry"; + + (await client.PutAsJsonAsync(url, Measured(5, 5, 5) with { Version = 2 }, Options)).EnsureSuccessStatusCode(); + (await client.PutAsJsonAsync(url, Measured(1, 1, 1), Options)).EnsureSuccessStatusCode(); + + var page = await DrawingLibraryApiTests.Query(client, ""); + Assert.Equal(5, Assert.Single(page.Items).Geometry!.Holes); + } + + [Theory] + [InlineData("{\"version\":0,\"length\":1,\"width\":1,\"area\":1,\"holes\":0}")] + [InlineData("{\"version\":1,\"length\":1,\"width\":2,\"area\":1,\"holes\":0}")] + [InlineData("{\"version\":1,\"length\":1,\"width\":1,\"area\":1,\"holes\":-1}")] + [InlineData("not json")] + public async Task InvalidSummary_IsRefused(string json) + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var package = Package("invalid"); + await Upload(client, Record("Invalid"), package); + + using var response = await client.PutAsync($"/api/drawing-packages/{Sha256(package)}/geometry", + new StringContent(json, System.Text.Encoding.UTF8, "application/json")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task UnknownOrMalformedHash_IsNotFoundOrRefused() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + + using var unknown = await client.PutAsJsonAsync($"/api/drawing-packages/{new string('a', 64)}/geometry", Measured(1, 1, 0), Options); + using var malformed = await client.GetAsync("/api/drawing-packages/ABC"); + using var badLimit = await client.GetAsync("/api/drawing-packages/unmeasured?version=1&limit=501"); + + Assert.Equal(HttpStatusCode.NotFound, unknown.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, malformed.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, badLimit.StatusCode); + } + + private static Drawing Washer(string name, double size, int holes) + { + var program = new CncProgram(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(0, size))); + program.Codes.Add(new LinearMove(new Vector(size, size))); + program.Codes.Add(new LinearMove(new Vector(size, 0))); + program.Codes.Add(new LinearMove(new Vector(0, 0))); + for (var i = 0; i < holes; i++) + { + var x = 0.25 + i * 0.75; + program.Codes.Add(new RapidMove(new Vector(x, 0.25))); + program.Codes.Add(new LinearMove(new Vector(x + 0.5, 0.25))); + program.Codes.Add(new LinearMove(new Vector(x + 0.5, 0.75))); + program.Codes.Add(new LinearMove(new Vector(x, 0.75))); + program.Codes.Add(new LinearMove(new Vector(x, 0.25))); + } + return new Drawing(name, program); + } + + [Fact] + public async Task WorkerBackfill_MeasuresRealPackages_SoTheSizeFilterFindsThem() + { + using var factory = new ServerFactory(); + using var client = factory.CreateClient(); + var url = client.BaseAddress!.ToString(); + using var library = new RemoteDrawingLibrary(client, url); + using var geometry = new RemoteDrawingGeometry(client, url); + foreach (var (name, size, holes) in new[] { ("Small two-hole", 3.0, 2), ("Small one-hole", 3.0, 1), ("Large two-hole", 6.0, 2) }) + { + var record = Record(name); + record.Thickness = 0.1046; + await library.UploadAsync(record, DrawingPackage.Write(Washer(name, size, holes), Units.Inches)); + } + var badPackage = Package("not a drawing"); + await Upload(client, Record("Not a drawing"), badPackage); + var backfill = new DrawingGeometryBackfill(geometry, NullLogger.Instance); + + Assert.Equal(4, await backfill.RunOnceAsync(CancellationToken.None)); + Assert.Equal(0, await backfill.RunOnceAsync(CancellationToken.None)); + + var page = await library.QueryAsync(new DrawingLibraryQuery + { + Filter = new DrawingLibraryFilter { MinThickness = 0.0598, MaxThickness = 0.1875, MaxLength = 4, MinHoles = 2 }, + }); + var found = Assert.Single(page.Items); + Assert.Equal("Small two-hole", found.Name); + Assert.Equal((3, 3, 2), (found.Geometry!.Length, found.Geometry.Width, found.Geometry.Holes)); + Assert.Equal(9 - 2 * 0.25, found.Geometry.Area, 6); + var failed = (await DrawingLibraryApiTests.Query(client, "search=Not%20a%20drawing")).Items.Single(); + Assert.False(failed.Geometry!.IsMeasured); + } +} diff --git a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs index 19be8a90..c36cd79c 100644 --- a/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs +++ b/OpenNest.Server.Tests/DrawingSetDatabaseTests.cs @@ -23,7 +23,7 @@ public sealed class DrawingSetDatabaseTests using (var database = new NestDatabase(factory.DatabasePath, pooling: false)) Assert.Empty(database.QueryDrawingSets(new DrawingSetQuery { IncludeArchived = true }).Items); Assert.Equal(before, Digest(factory.DatabasePath)); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); @@ -48,7 +48,7 @@ public sealed class DrawingSetDatabaseTests Assert.Equal(before, Digest(factory.DatabasePath)); Execute(factory.DatabasePath, "DROP TABLE ix_drawing_set_members_drawing"); using var database = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } [Fact] diff --git a/OpenNest.Server.Tests/MaterialListTests.cs b/OpenNest.Server.Tests/MaterialListTests.cs index 0623e578..5177668b 100644 --- a/OpenNest.Server.Tests/MaterialListTests.cs +++ b/OpenNest.Server.Tests/MaterialListTests.cs @@ -25,7 +25,7 @@ public sealed class MaterialListTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListMaterials()); Assert.Equal((" Stainless ", "304"), (upgraded.Get(nest)!.Material, upgraded.Get(nest)!.MaterialGrade)); Assert.Equal(drawing.MaterialName, upgraded.GetDrawing(drawing.Id)!.MaterialName); diff --git a/OpenNest.Server.Tests/NestMaterialGradeTests.cs b/OpenNest.Server.Tests/NestMaterialGradeTests.cs index 99fa4f6a..d8f614c1 100644 --- a/OpenNest.Server.Tests/NestMaterialGradeTests.cs +++ b/OpenNest.Server.Tests/NestMaterialGradeTests.cs @@ -75,7 +75,7 @@ public sealed class NestMaterialGradeTests Assert.Equal("Stainless", record.Material); Assert.Equal("", record.MaterialGrade); Assert.Equal("", Assert.Single(upgraded.History(id, 0, 10)!.Items).MaterialGrade); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, string material, string grade) => new() diff --git a/OpenNest.Server.Tests/NestStockCodesTests.cs b/OpenNest.Server.Tests/NestStockCodesTests.cs index 2897da11..087ab69a 100644 --- a/OpenNest.Server.Tests/NestStockCodesTests.cs +++ b/OpenNest.Server.Tests/NestStockCodesTests.cs @@ -119,7 +119,7 @@ public sealed class NestStockCodesTests database.SetDeleted(deleted, false, record.Revision); Assert.Equal(["SHEET-1", "SHEET-2"], database.StockCodesInUse(null).Select(u => u.ItemCode)); } - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static Nest Nest(string name, NestStatus status, params string[][] plateCodes) diff --git a/OpenNest.Server.Tests/NestUnitsTests.cs b/OpenNest.Server.Tests/NestUnitsTests.cs index bddfc562..afef63b7 100644 --- a/OpenNest.Server.Tests/NestUnitsTests.cs +++ b/OpenNest.Server.Tests/NestUnitsTests.cs @@ -77,7 +77,7 @@ public sealed class NestUnitsTests Assert.Equal([Units.Millimeters, Units.Millimeters], upgraded.History(metric, 0, 10)!.Items.Select(r => r.Units)); Assert.Equal(Units.Inches, upgraded.Get(inch)!.Units); Assert.Equal(Units.Inches, upgraded.Get(unreadable)!.Units); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); } private static NestRecord Record(string name, Units units) => new() diff --git a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs index abc32ada..930186b1 100644 --- a/OpenNest.Server.Tests/SetReleaseDrawingTests.cs +++ b/OpenNest.Server.Tests/SetReleaseDrawingTests.cs @@ -152,7 +152,7 @@ public sealed class SetReleaseDrawingTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); var kept = upgraded.GetRelease(releaseId)!; Assert.Empty(kept.Drawings); Assert.Equal(((Guid?)kit.Id, drawing, 2), (kept.Nests[0].Items.Single().SetId, kept.Nests[0].Items.Single().DrawingId, kept.Nests[0].Items.Single().Quantity)); diff --git a/OpenNest.Server.Tests/SetReleaseTests.cs b/OpenNest.Server.Tests/SetReleaseTests.cs index b2bdc7c9..58ca473c 100644 --- a/OpenNest.Server.Tests/SetReleaseTests.cs +++ b/OpenNest.Server.Tests/SetReleaseTests.cs @@ -295,7 +295,7 @@ public sealed class SetReleaseTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Equal(0L, Scalar(factory.DatabasePath, "SELECT COUNT(*) FROM sqlite_master WHERE name LIKE 'set_release%' OR name LIKE 'ix_set_release%'")); var release = upgraded.GetRelease(releaseId)!; Assert.Equal(("Legacy notes", "Legacy operator", false), (release.Notes, release.ReleasedBy, release.IsUndone)); diff --git a/OpenNest.Server.Tests/SharedSettingsTests.cs b/OpenNest.Server.Tests/SharedSettingsTests.cs index a032da67..3d3a897a 100644 --- a/OpenNest.Server.Tests/SharedSettingsTests.cs +++ b/OpenNest.Server.Tests/SharedSettingsTests.cs @@ -101,7 +101,7 @@ public sealed class SharedSettingsTests using var upgraded = new NestDatabase(factory.DatabasePath, pooling: false); - Assert.Equal(18L, Scalar(factory.DatabasePath, "PRAGMA user_version")); + Assert.Equal(19L, Scalar(factory.DatabasePath, "PRAGMA user_version")); Assert.Empty(upgraded.ListSharedSettings()); } diff --git a/OpenNest.Server.Worker/DrawingGeometryBackfill.cs b/OpenNest.Server.Worker/DrawingGeometryBackfill.cs new file mode 100644 index 00000000..dd473cae --- /dev/null +++ b/OpenNest.Server.Worker/DrawingGeometryBackfill.cs @@ -0,0 +1,121 @@ +using OpenNest.Data; +using OpenNest.IO; + +namespace OpenNest.Server.Worker; + +/// +/// Measures library drawing packages the server lists as unmeasured and stores each +/// , so the library can be filtered by size and holes. +/// A package that cannot be read or measured is stored as failed and not tried again; a +/// server or network failure stores nothing, so the package is tried on a later round. +/// +public sealed class DrawingGeometryBackfill +{ + public const int BatchSize = 100; + + // A pathological drawing must not stall the rest; its worker thread is abandoned. + private static readonly TimeSpan MeasureTimeout = TimeSpan.FromSeconds(30); + + private readonly RemoteDrawingGeometry _server; + private readonly ILogger _logger; + + public DrawingGeometryBackfill(RemoteDrawingGeometry server, ILogger logger) + { + _server = server; + _logger = logger; + } + + /// Measures one batch; returns how many summaries were stored (0 when none were waiting). + public async Task RunOnceAsync(CancellationToken cancellationToken) + { + var hashes = await _server.GetUnmeasuredAsync(DrawingGeometrySummary.CurrentVersion, BatchSize, cancellationToken); + var stored = 0; + foreach (var sha in hashes) + { + cancellationToken.ThrowIfCancellationRequested(); + var package = await _server.GetPackageAsync(sha, cancellationToken); + if (package is null) + continue; + var summary = await MeasureAsync(package, cancellationToken); + if (!summary.IsMeasured) + _logger.LogInformation("Library package {Sha} could not be measured: {Error}", sha, summary.Error); + if (await _server.PutAsync(sha, summary, cancellationToken)) + stored++; + } + return stored; + } + + /// Reads and measures one package; a failure becomes a failed summary. + public static async Task MeasureAsync(byte[] package, CancellationToken cancellationToken) + { + try + { + return await Task.Run(() => + { + var contents = DrawingPackage.Read(package); + return DrawingGeometrySummary.Measure(contents.Drawing, contents.Units); + }, cancellationToken).WaitAsync(MeasureTimeout, cancellationToken); + } + catch (TimeoutException) + { + return DrawingGeometrySummary.Failed($"Measuring took over {MeasureTimeout.TotalSeconds:0} s."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return DrawingGeometrySummary.Failed(ex.Message); + } + } +} + +/// +/// Runs beside the nest job worker: batch after batch +/// while packages are waiting, then every . +/// +public sealed class DrawingGeometryBackfillService : BackgroundService +{ + private readonly DrawingGeometryBackfill _backfill; + private readonly WorkerOptions _options; + private readonly ILogger _logger; + + public DrawingGeometryBackfillService(DrawingGeometryBackfill backfill, WorkerOptions options, + ILogger logger) + { + _backfill = backfill; + _options = options; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var total = 0; + while (!stoppingToken.IsCancellationRequested) + { + var stored = 0; + try + { + stored = await _backfill.RunOnceAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (ServerTooOldException) + { + _logger.LogWarning("The nest server does not measure library drawings; update it to filter the library by size."); + } + catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException) + { + _logger.LogWarning("Nest server unavailable for library measurement: {Message}", ex.Message); + } + + if (stored > 0) + { + total += stored; + _logger.LogInformation("Measured {Count} library drawing package(s) so far.", total); + continue; + } + try { await Task.Delay(_options.GeometryPollInterval, stoppingToken); } + catch (OperationCanceledException) { return; } + } + } +} diff --git a/OpenNest.Server.Worker/Program.cs b/OpenNest.Server.Worker/Program.cs index 82467ddb..f2706d73 100644 --- a/OpenNest.Server.Worker/Program.cs +++ b/OpenNest.Server.Worker/Program.cs @@ -3,8 +3,9 @@ using OpenNest.Data; using OpenNest.Server.Worker; // The nest server's worker: takes queued whole-job nests from OPENNEST_SERVER_URL and solves -// them one at a time. Run it in its own container with CPU and memory limits (compose.server.yaml) -// so nesting never slows nest open and save. See docs/server-nesting.md. +// them one at a time, and measures library drawings for the library's size filters. Run it in +// its own container with CPU and memory limits (compose.server.yaml) so nesting never slows +// nest open and save. See docs/server-nesting.md. var options = WorkerOptions.FromEnvironment(Environment.GetEnvironmentVariable); // Below the server and anything else on the host when they share CPUs; the container's @@ -18,6 +19,10 @@ builder.Services.AddSingleton(_ => new RemoteNestJobs(options.ServerUrl)); builder.Services.AddSingleton(sp => new NestJobWorker( sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddHostedService(); +// Measures library drawings for the size and hole filters; see DrawingGeometryBackfill. +builder.Services.AddSingleton(_ => new RemoteDrawingGeometry(options.ServerUrl)); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); // A running job is given back, not abandoned, when the container stops. builder.Services.Configure(o => o.ShutdownTimeout = TimeSpan.FromSeconds(20)); builder.Build().Run(); diff --git a/OpenNest.Server.Worker/WorkerOptions.cs b/OpenNest.Server.Worker/WorkerOptions.cs index a4bde964..cdbbe90b 100644 --- a/OpenNest.Server.Worker/WorkerOptions.cs +++ b/OpenNest.Server.Worker/WorkerOptions.cs @@ -13,6 +13,9 @@ public sealed class WorkerOptions /// How long to wait before asking again when no job was queued. public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5); + /// How long library measurement waits before asking again when nothing was waiting. + public TimeSpan GeometryPollInterval { get; init; } = TimeSpan.FromSeconds(60); + /// Touched every round; the container health check fails when it goes stale. public string AliveFile { get; init; } = Path.Combine(Path.GetTempPath(), "opennest-worker.alive"); diff --git a/OpenNest.Server/DrawingLibraryRequest.cs b/OpenNest.Server/DrawingLibraryRequest.cs index 559b8fdb..d53ff7e6 100644 --- a/OpenNest.Server/DrawingLibraryRequest.cs +++ b/OpenNest.Server/DrawingLibraryRequest.cs @@ -16,6 +16,8 @@ internal static class DrawingLibraryRequest { "search", "customer", "materialName", "materialGrade", "thickness", "cutType", "cutFirst", "includeArchived", "sort", "order", "offset", "limit", + "minThickness", "maxThickness", "includeBlankThickness", "minLength", "maxLength", "minWidth", "maxWidth", + "minHoles", "maxHoles", "savedFrom", "savedTo", }; // Integer enum values are refused; names are additionally checked before deserializing. @@ -113,6 +115,9 @@ internal static class DrawingLibraryRequest if (!TryInteger(values, "offset", ref offset, out error) || !TryInteger(values, "limit", ref limit, out error)) return false; + if (!TryReadFilter(values, out var filter, out error)) + return false; + query = new DrawingLibraryQuery { Search = values["search"].ToString(), @@ -127,11 +132,95 @@ internal static class DrawingLibraryRequest Descending = descending, Offset = offset, Limit = limit, + Filter = filter, }; error = query.GetValidationError() ?? ""; return error.Length == 0; } + // Thickness and sizes are inches; holes whole numbers; dates yyyy-MM-dd. The customer stays on the query. + private static bool TryReadFilter(IQueryCollection values, out DrawingLibraryFilter filter, out string error) + { + filter = DrawingLibraryFilter.None; + double? minThickness = null, maxThickness = null, minLength = null, maxLength = null, minWidth = null, maxWidth = null; + int? minHoles = null, maxHoles = null; + DateOnly? savedFrom = null, savedTo = null; + var includeBlank = true; + if (!TryNumber(values, "minThickness", ref minThickness, out error) || !TryNumber(values, "maxThickness", ref maxThickness, out error) + || !TryNumber(values, "minLength", ref minLength, out error) || !TryNumber(values, "maxLength", ref maxLength, out error) + || !TryNumber(values, "minWidth", ref minWidth, out error) || !TryNumber(values, "maxWidth", ref maxWidth, out error) + || !TryCount(values, "minHoles", ref minHoles, out error) || !TryCount(values, "maxHoles", ref maxHoles, out error) + || !TryDate(values, "savedFrom", ref savedFrom, out error) || !TryDate(values, "savedTo", ref savedTo, out error)) + return false; + + if (values.TryGetValue("includeBlankThickness", out var blankText) && !bool.TryParse(blankText.ToString(), out includeBlank)) + { + error = "includeBlankThickness must be true or false."; + return false; + } + + filter = new DrawingLibraryFilter + { + MinThickness = minThickness, + MaxThickness = maxThickness, + IncludeBlankThickness = includeBlank, + MinLength = minLength, + MaxLength = maxLength, + MinWidth = minWidth, + MaxWidth = maxWidth, + MinHoles = minHoles, + MaxHoles = maxHoles, + SavedFrom = savedFrom, + SavedTo = savedTo, + }; + return true; + } + + private static bool TryNumber(IQueryCollection values, string key, ref double? value, out string error) + { + error = ""; + if (!values.TryGetValue(key, out var text)) + return true; + if (double.TryParse(text.ToString(), NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent, CultureInfo.InvariantCulture, out var parsed) + && double.IsFinite(parsed)) + { + value = parsed; + return true; + } + + error = $"{key} must be a finite number, zero or greater."; + return false; + } + + private static bool TryCount(IQueryCollection values, string key, ref int? value, out string error) + { + var parsed = 0; + if (!values.ContainsKey(key)) + { + error = ""; + return true; + } + if (!TryInteger(values, key, ref parsed, out error)) + return false; + value = parsed; + return true; + } + + private static bool TryDate(IQueryCollection values, string key, ref DateOnly? value, out string error) + { + error = ""; + if (!values.TryGetValue(key, out var text)) + return true; + if (DateOnly.TryParseExact(text.ToString(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + value = parsed; + return true; + } + + error = $"{key} must be a date written yyyy-MM-dd."; + return false; + } + /// Reads and validates one metadata JSON object. public static bool TryReadMetadata(string? json, out DrawingLibraryRecord record, out string error) { diff --git a/OpenNest.Server/DrawingLibraryRoutes.cs b/OpenNest.Server/DrawingLibraryRoutes.cs index b1e547fc..09c47f5a 100644 --- a/OpenNest.Server/DrawingLibraryRoutes.cs +++ b/OpenNest.Server/DrawingLibraryRoutes.cs @@ -20,6 +20,48 @@ internal static class DrawingLibraryRoutes ? Results.Ok(db.QueryDrawings(query)) : Results.BadRequest(error)); + // Measurement for the worker: the server never parses packages (see NestDatabase.DrawingGeometry). + app.MapGet("/api/drawing-packages/unmeasured", (HttpRequest request, NestDatabase db) => + { + var version = 0; + var limit = 100; + foreach (var key in request.Query.Keys) + { + if (key is not ("version" or "limit") || request.Query[key].Count > 1) + return Results.BadRequest($"Unknown or repeated parameter '{key}'."); + } + if (!DrawingLibraryRequest.TryInteger(request.Query, "version", ref version, out var error) + || !DrawingLibraryRequest.TryInteger(request.Query, "limit", ref limit, out error)) + return Results.BadRequest(error); + if (version < 1 || limit < 1 || limit > RemoteDrawingGeometry.MaxBatch) + return Results.BadRequest($"version must be 1 or greater and limit 1 to {RemoteDrawingGeometry.MaxBatch}."); + return Results.Ok(db.GetUnmeasuredPackages(version, limit)); + }); + + app.MapGet("/api/drawing-packages/{sha}", (string sha, NestDatabase db) => + !RemoteDrawingGeometry.IsSha256(sha) ? Results.BadRequest("Invalid package hash.") : + db.GetPackageBySha(sha) is { } package ? Results.File(package, "application/zip", $"{sha}.nest") : Results.NotFound()); + + app.MapPut("/api/drawing-packages/{sha}/geometry", async (string sha, HttpRequest request, NestDatabase db) => + { + if (!RemoteDrawingGeometry.IsSha256(sha)) + return Results.BadRequest("Invalid package hash."); + DrawingGeometrySummary? summary; + try + { + summary = await request.ReadFromJsonAsync(request.HttpContext.RequestAborted); + } + catch (System.Text.Json.JsonException) + { + return Results.BadRequest("Invalid geometry JSON."); + } + if (summary is null) + return Results.BadRequest("Missing geometry."); + if (summary.GetValidationError() is { } invalid) + return Results.BadRequest(invalid); + return db.SetPackageGeometry(sha, summary) ? Results.NoContent() : Results.NotFound(); + }); + app.MapGet("/api/drawings/{id:guid}", (Guid id, NestDatabase db) => db.GetDrawing(id) is { } record ? Results.Ok(record) : Results.NotFound()); diff --git a/OpenNest.Server/NestDatabase.DrawingGeometry.cs b/OpenNest.Server/NestDatabase.DrawingGeometry.cs new file mode 100644 index 00000000..dfd3377a --- /dev/null +++ b/OpenNest.Server/NestDatabase.DrawingGeometry.cs @@ -0,0 +1,188 @@ +using System.Globalization; +using Microsoft.Data.Sqlite; +using OpenNest.Data; + +namespace OpenNest.Server; + +/// +/// Measured library package sizes. The server never parses packages, so the worker lists the +/// packages of current drawings without a summary at its version, measures each and stores it +/// here keyed by package hash: identical geometry is measured once, and a metadata-only +/// revision keeps its summary. Queries join it to filter and sort by size and holes. +/// +public sealed partial class NestDatabase +{ + // v19: one summary per package; error is nonblank (and the sizes 0) when it could not be measured. + private const string DrawingGeometrySchema = """ + CREATE TABLE IF NOT EXISTS drawing_package_geometry ( + sha256 TEXT NOT NULL PRIMARY KEY REFERENCES drawing_packages(sha256), + version INTEGER NOT NULL, + length REAL NOT NULL, + width REAL NOT NULL, + area REAL NOT NULL, + holes INTEGER NOT NULL, + error TEXT NOT NULL DEFAULT '', + measuredAt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS ix_drawing_package_geometry_length ON drawing_package_geometry(length); + PRAGMA user_version=19; + """; + + // Lengths and thickness compare in inches; the slack absorbs rounding and millimeter conversion. + private const double RangeSlack = 1e-6; + + private const string GeometryJoin = "LEFT JOIN drawing_package_geometry g ON g.sha256 = drawings.packageSha256"; + + private const string GeometryColumns = "g.version, g.length, g.width, g.area, g.holes, g.error"; + + private const string ThicknessInches = "(CASE WHEN units = 'Millimeters' THEN thickness / 25.4 ELSE thickness END)"; + + /// + /// Up to distinct package hashes of current drawings (archived ones + /// included) with no summary at or later, in hash order. + /// + public IReadOnlyList GetUnmeasuredPackages(int version, int limit) + { + ArgumentOutOfRangeException.ThrowIfLessThan(version, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(limit, 1); + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = """ + SELECT DISTINCT d.packageSha256 FROM drawings d + LEFT JOIN drawing_package_geometry g ON g.sha256 = d.packageSha256 + WHERE g.sha256 IS NULL OR g.version < $version + ORDER BY d.packageSha256 LIMIT $limit + """; + command.Parameters.AddWithValue("$version", version); + command.Parameters.AddWithValue("$limit", limit); + var hashes = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + hashes.Add(reader.GetString(0)); + return hashes; + } + } + + /// The package stored under a hash, or null. + public byte[]? GetPackageBySha(string sha256) + { + lock (_sync) + { + using var command = _connection.CreateCommand(); + command.CommandText = "SELECT package FROM drawing_packages WHERE sha256=$sha"; + command.Parameters.AddWithValue("$sha", sha256); + return command.ExecuteScalar() as byte[]; + } + } + + /// + /// Stores a package's summary; false when the package is unknown. A summary from an older + /// version than the stored one is ignored, so an outdated worker cannot undo a newer one. + /// + public bool SetPackageGeometry(string sha256, DrawingGeometrySummary summary) + { + ArgumentNullException.ThrowIfNull(summary); + var error = summary.GetValidationError(); + if (error is not null) + throw new ArgumentException(error, nameof(summary)); + + lock (_sync) + { + using var exists = _connection.CreateCommand(); + exists.CommandText = "SELECT COUNT(*) FROM drawing_packages WHERE sha256=$sha"; + exists.Parameters.AddWithValue("$sha", sha256); + if ((long)exists.ExecuteScalar()! == 0) + return false; + + using var command = _connection.CreateCommand(); + command.CommandText = """ + INSERT INTO drawing_package_geometry (sha256, version, length, width, area, holes, error, measuredAt) + VALUES ($sha, $version, $length, $width, $area, $holes, $error, $measuredAt) + ON CONFLICT(sha256) DO UPDATE SET + version = excluded.version, length = excluded.length, width = excluded.width, + area = excluded.area, holes = excluded.holes, error = excluded.error, measuredAt = excluded.measuredAt + WHERE excluded.version >= drawing_package_geometry.version + """; + var measured = summary.IsMeasured; + command.Parameters.AddWithValue("$sha", sha256); + command.Parameters.AddWithValue("$version", summary.Version); + command.Parameters.AddWithValue("$length", measured ? summary.Length : 0); + command.Parameters.AddWithValue("$width", measured ? summary.Width : 0); + command.Parameters.AddWithValue("$area", measured ? summary.Area : 0); + command.Parameters.AddWithValue("$holes", measured ? summary.Holes : 0); + command.Parameters.AddWithValue("$error", summary.Error); + command.Parameters.AddWithValue("$measuredAt", DateTime.Now.ToString("o", CultureInfo.InvariantCulture)); + command.ExecuteNonQuery(); + return true; + } + } + + // The filter's ranges as WHERE conditions over drawings joined to their geometry (GeometryJoin). + private static void AddRangeConditions(List conditions, DrawingLibraryFilter filter) + { + if (filter.MinThickness is not null || filter.MaxThickness is not null) + { + var range = new List { "thickness > 0" }; + if (filter.MinThickness is not null) range.Add($"{ThicknessInches} >= $minThickness"); + if (filter.MaxThickness is not null) range.Add($"{ThicknessInches} <= $maxThickness"); + var inRange = "(" + string.Join(" AND ", range) + ")"; + // A drawing saved without a thickness suits any, as with the exact thickness filter. + conditions.Add(filter.IncludeBlankThickness ? $"({inRange} OR thickness = 0)" : inRange); + } + else if (!filter.IncludeBlankThickness) + { + conditions.Add("thickness > 0"); + } + + if (filter.NeedsGeometry) + conditions.Add("g.error = ''"); + if (filter.MinLength is not null) conditions.Add("g.length >= $minLength"); + if (filter.MaxLength is not null) conditions.Add("g.length <= $maxLength"); + if (filter.MinWidth is not null) conditions.Add("g.width >= $minWidth"); + if (filter.MaxWidth is not null) conditions.Add("g.width <= $maxWidth"); + if (filter.MinHoles is not null) conditions.Add("g.holes >= $minHoles"); + if (filter.MaxHoles is not null) conditions.Add("g.holes <= $maxHoles"); + // savedAt is the server's local ISO time, so its first ten characters are its local date. + if (filter.SavedFrom is not null) conditions.Add("substr(savedAt, 1, 10) >= $savedFrom"); + if (filter.SavedTo is not null) conditions.Add("substr(savedAt, 1, 10) <= $savedTo"); + } + + private static void AddRangeParameters(SqliteCommand command, DrawingLibraryFilter filter) + { + AddLower(command, "$minThickness", filter.MinThickness); + AddUpper(command, "$maxThickness", filter.MaxThickness); + AddLower(command, "$minLength", filter.MinLength); + AddUpper(command, "$maxLength", filter.MaxLength); + AddLower(command, "$minWidth", filter.MinWidth); + AddUpper(command, "$maxWidth", filter.MaxWidth); + if (filter.MinHoles is { } minHoles) command.Parameters.AddWithValue("$minHoles", minHoles); + if (filter.MaxHoles is { } maxHoles) command.Parameters.AddWithValue("$maxHoles", maxHoles); + if (filter.SavedFrom is { } from) command.Parameters.AddWithValue("$savedFrom", from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + if (filter.SavedTo is { } to) command.Parameters.AddWithValue("$savedTo", to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + } + + private static void AddLower(SqliteCommand command, string name, double? value) + { + if (value is { } number) + command.Parameters.AddWithValue(name, number - RangeSlack); + } + + private static void AddUpper(SqliteCommand command, string name, double? value) + { + if (value is { } number) + command.Parameters.AddWithValue(name, number + RangeSlack); + } + + // Reads GeometryColumns from column `first` on; null when the package has no summary yet. + private static DrawingGeometrySummary? ReadGeometry(SqliteDataReader reader, int first) => + reader.IsDBNull(first) ? null : new DrawingGeometrySummary + { + Version = reader.GetInt32(first), + Length = reader.GetDouble(first + 1), + Width = reader.GetDouble(first + 2), + Area = reader.GetDouble(first + 3), + Holes = reader.GetInt32(first + 4), + Error = reader.GetString(first + 5), + }; +} diff --git a/OpenNest.Server/NestDatabase.Drawings.cs b/OpenNest.Server/NestDatabase.Drawings.cs index c2f4385f..3581a936 100644 --- a/OpenNest.Server/NestDatabase.Drawings.cs +++ b/OpenNest.Server/NestDatabase.Drawings.cs @@ -103,6 +103,9 @@ public sealed partial class NestDatabase cutFirst, priority, stepAngle, startAngle, endAngle, allow180Equivalent, colorArgb """; + // The number of DrawingColumns, so joined columns can follow them. + private const int DrawingColumnCount = 22; + private const string DrawingStoredColumns = DrawingColumns + ", nameKey, customerKey, materialKey, gradeKey"; // Case-insensitive (ASCII) substring; the text is always a bound, escaped LIKE parameter. @@ -147,26 +150,27 @@ public sealed partial class NestDatabase var conditions = new List(); if (!query.IncludeArchived) conditions.Add("archived=0"); if (query.NormalizedSearch.Length > 0) conditions.Add("(" + DrawingSearchPredicate + ")"); - if (SharedListNames.Key(query.Customer).Length > 0) conditions.Add("customerKey=$customer"); + if (SharedListNames.Key(query.EffectiveCustomer).Length > 0) conditions.Add("customerKey=$customer"); // A drawing saved without a material, grade or thickness (0) suits any, so it matches every filter value. if (SharedListNames.Key(query.MaterialName).Length > 0) conditions.Add("(materialKey=$material OR materialKey='')"); if (SharedListNames.Key(query.MaterialGrade).Length > 0) conditions.Add("(gradeKey=$grade OR gradeKey='')"); if (query.Thickness is not null) conditions.Add("(thickness=$thickness OR thickness=0)"); if (query.CutType is not null) conditions.Add("cutType=$cutType"); if (query.CutFirst is not null) conditions.Add("cutFirst=$cutFirst"); + AddRangeConditions(conditions, query.Filter); var where = conditions.Count == 0 ? "" : "WHERE " + string.Join(" AND ", conditions); lock (_sync) { using var count = _connection.CreateCommand(); - count.CommandText = $"SELECT COUNT(*) FROM drawings {where}"; + count.CommandText = $"SELECT COUNT(*) FROM drawings {GeometryJoin} {where}"; AddDrawingFilterParameters(count, query); var total = checked((int)(long)count.ExecuteScalar()!); using var command = _connection.CreateCommand(); var direction = query.Descending ? "DESC" : "ASC"; command.CommandText = $""" - SELECT {DrawingColumns} FROM drawings {where} + SELECT {DrawingColumns}, {GeometryColumns} FROM drawings {GeometryJoin} {where} ORDER BY {DrawingSortColumn(query.Sort)} {direction}, id {direction} LIMIT $limit OFFSET $offset """; @@ -176,7 +180,11 @@ public sealed partial class NestDatabase var records = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) - records.Add(ReadDrawing(reader)); + { + var record = ReadDrawing(reader); + record.Geometry = ReadGeometry(reader, DrawingColumnCount); + records.Add(record); + } return new DrawingLibraryPage { Items = records, Total = total, Offset = query.Offset, Limit = query.Limit }; } } @@ -367,8 +375,9 @@ public sealed partial class NestDatabase private static void AddDrawingFilterParameters(SqliteCommand command, DrawingLibraryQuery query) { AddSearchParameter(command, query.NormalizedSearch); - if (SharedListNames.Key(query.Customer).Length > 0) - command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.Customer)); + if (SharedListNames.Key(query.EffectiveCustomer).Length > 0) + command.Parameters.AddWithValue("$customer", SharedListNames.Key(query.EffectiveCustomer)); + AddRangeParameters(command, query.Filter); if (SharedListNames.Key(query.MaterialName).Length > 0) command.Parameters.AddWithValue("$material", SharedListNames.Key(query.MaterialName)); if (SharedListNames.Key(query.MaterialGrade).Length > 0) @@ -392,6 +401,8 @@ public sealed partial class NestDatabase DrawingSortField.Thickness => "thickness", DrawingSortField.CutType => "cutType", DrawingSortField.Revision => "revision", + DrawingSortField.Length => "g.length", + DrawingSortField.Holes => "g.holes", _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort column."), }; diff --git a/OpenNest.Server/NestDatabase.cs b/OpenNest.Server/NestDatabase.cs index cf6747d7..8d746b4f 100644 --- a/OpenNest.Server/NestDatabase.cs +++ b/OpenNest.Server/NestDatabase.cs @@ -344,7 +344,7 @@ public sealed partial class NestDatabase : IDisposable command.Transaction = transaction; command.CommandText = "PRAGMA user_version"; var version = (long)command.ExecuteScalar()!; - if (version > 18) throw new InvalidOperationException("Database schema is newer than this server."); + if (version > 19) throw new InvalidOperationException("Database schema is newer than this server."); if (version == 0) { command.CommandText = """ @@ -455,6 +455,12 @@ public sealed partial class NestDatabase : IDisposable command.CommandText = DrawingPriorityUpgradeToV18; command.ExecuteNonQuery(); } + if (version < 19) + { + // Measured package sizes, empty until the worker measures them. + command.CommandText = DrawingGeometrySchema; + command.ExecuteNonQuery(); + } transaction.Commit(); } } diff --git a/OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs b/OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs new file mode 100644 index 00000000..9885de52 --- /dev/null +++ b/OpenNest.Tests/Data/DrawingGeometrySummaryTests.cs @@ -0,0 +1,67 @@ +using OpenNest.CNC; +using OpenNest.Data; +using OpenNest.Geometry; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Tests.Data; + +public class DrawingGeometrySummaryTests +{ + private static void Rectangle(CncProgram program, double x, double y, double width, double height) + { + program.Codes.Add(new RapidMove(new Vector(x, y))); + program.Codes.Add(new LinearMove(new Vector(x, y + height))); + program.Codes.Add(new LinearMove(new Vector(x + width, y + height))); + program.Codes.Add(new LinearMove(new Vector(x + width, y))); + program.Codes.Add(new LinearMove(new Vector(x, y))); + } + + [Fact] + public void Measure_ReportsTheBoundingSidesNetAreaAndHolesInInches() + { + var program = new CncProgram(); + Rectangle(program, 0, 0, 2, 5); + Rectangle(program, 0.5, 0.5, 1, 1); + Rectangle(program, 0.5, 3, 1, 1); + + var summary = DrawingGeometrySummary.Measure(new Drawing("Synthetic plate", program), Units.Inches); + + Assert.True(summary.IsMeasured); + Assert.Equal((5.0, 2.0, 8.0, 2), (summary.Length, summary.Width, summary.Area, summary.Holes)); + Assert.Equal(DrawingGeometrySummary.CurrentVersion, summary.Version); + Assert.Null(summary.GetValidationError()); + } + + [Fact] + public void Measure_ConvertsMillimeterDrawingsToInches() + { + var program = new CncProgram(); + Rectangle(program, 0, 0, 50.8, 25.4); + + var summary = DrawingGeometrySummary.Measure(new Drawing("Synthetic metric", program), Units.Millimeters); + + Assert.Equal((2.0, 1.0, 2.0, 0), (summary.Length, summary.Width, summary.Area, summary.Holes)); + } + + [Fact] + public void Measure_WithoutClosedCutGeometry_IsAFailedSummary() + { + var program = new CncProgram(); + program.Codes.Add(new RapidMove(new Vector(0, 0))); + program.Codes.Add(new LinearMove(new Vector(3, 0))); + + var summary = DrawingGeometrySummary.Measure(new Drawing("Synthetic line", program), Units.Inches); + + Assert.False(summary.IsMeasured); + Assert.Null(summary.GetValidationError()); + } + + [Fact] + public void Failed_TrimsLongErrorsToTheStoredLimit() + { + var summary = DrawingGeometrySummary.Failed(new string('x', 500)); + + Assert.Equal(DrawingGeometrySummary.MaxErrorLength, summary.Error.Length); + Assert.Null(summary.GetValidationError()); + } +} diff --git a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs index 8166c30c..8db44acf 100644 --- a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs +++ b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs @@ -99,6 +99,34 @@ public class DrawingLibraryBrowseSessionTests Assert.Equal((DrawingSortField.Name, true), (library.Queries[^1].Sort, library.Queries[^1].Descending)); } + [Fact] + public async Task Filter_IsSentWithTheNestFilterAndReturnsToTheFirstPage() + { + var library = new PagedLibrary(25); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + var filter = new DrawingLibraryFilter { Customer = "Synthetic Co", MaxLength = 4, MinHoles = 2 }; + + await session.SetFilterAsync(filter); + + var query = library.Queries[^1]; + Assert.Same(filter, query.Filter); + Assert.Equal(("Synthetic Co", "Synthetic Steel", 0), (query.EffectiveCustomer, query.MaterialName, query.Offset)); + await Assert.ThrowsAsync(() => session.SetFilterAsync(new DrawingLibraryFilter { MinHoles = 3, MaxHoles = 2 })); + Assert.Same(filter, session.Filter); + } + + [Fact] + public async Task Summary_NamesTheFilterWhenItLeavesNothing() + { + using var session = new DrawingLibraryBrowseSession(new PagedLibrary(0), null); + + await session.SetFilterAsync(new DrawingLibraryFilter { MaxLength = 4 }); + + Assert.Equal("No drawings match the filter.", session.Summary); + } + [Theory] [InlineData(false, "", "No library drawings match this nest's material and thickness. Tick Show all materials to see every drawing.")] [InlineData(true, "", "No drawings in the library.")] diff --git a/OpenNest.Tests/Data/DrawingLibraryFilterTests.cs b/OpenNest.Tests/Data/DrawingLibraryFilterTests.cs new file mode 100644 index 00000000..7efc3967 --- /dev/null +++ b/OpenNest.Tests/Data/DrawingLibraryFilterTests.cs @@ -0,0 +1,62 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class DrawingLibraryFilterTests +{ + [Fact] + public void None_IsEmptyAndNeedsNoGeometry() + { + Assert.True(new DrawingLibraryFilter().IsEmpty); + Assert.False(DrawingLibraryFilter.None.NeedsGeometry); + Assert.Equal("", DrawingLibraryFilter.None.Describe()); + Assert.Null(DrawingLibraryFilter.None.GetValidationError()); + Assert.False(new DrawingLibraryFilter { IncludeBlankThickness = false }.IsEmpty); + Assert.True(new DrawingLibraryFilter { MinHoles = 0 }.NeedsGeometry); + } + + public static TheoryData InvalidFilters() => new() + { + new DrawingLibraryFilter { MinThickness = 0.25, MaxThickness = 0.1 }, + new DrawingLibraryFilter { MinThickness = -0.1 }, + new DrawingLibraryFilter { MaxLength = double.PositiveInfinity }, + new DrawingLibraryFilter { MinWidth = 2, MaxWidth = 1 }, + new DrawingLibraryFilter { MinHoles = 3, MaxHoles = 2 }, + new DrawingLibraryFilter { MaxHoles = -1 }, + new DrawingLibraryFilter { SavedFrom = new DateOnly(2026, 2, 1), SavedTo = new DateOnly(2026, 1, 1) }, + new DrawingLibraryFilter { Customer = new string('x', DrawingLibraryRules.MaxTextLength + 1) }, + }; + + [Theory] + [MemberData(nameof(InvalidFilters))] + public void InvalidRanges_AreReported(DrawingLibraryFilter filter) => + Assert.NotNull(filter.GetValidationError()); + + [Fact] + public void Describe_SummarizesEachLimit() + { + var filter = new DrawingLibraryFilter + { + Customer = " Synthetic Co ", + MinThickness = 0.0598, + MaxThickness = 0.1875, + MaxLength = 4, + MinWidth = 1, + MaxWidth = 1, + MinHoles = 2, + }; + + Assert.Equal("Customer Synthetic Co; Thickness 0.0598-0.1875 in (or none); Length up to 4 in; Width 1 in; Holes 2+", + filter.Describe()); + Assert.Equal("Has a thickness", new DrawingLibraryFilter { IncludeBlankThickness = false }.Describe()); + } + + [Fact] + public void EffectiveCustomer_PrefersTheQueryCustomer() + { + var filter = new DrawingLibraryFilter { Customer = "Filter Co" }; + + Assert.Equal("Filter Co", new DrawingLibraryQuery { Filter = filter }.EffectiveCustomer); + Assert.Equal("Query Co", new DrawingLibraryQuery { Customer = "Query Co", Filter = filter }.EffectiveCustomer); + } +} diff --git a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs index 01d92440..e4474791 100644 --- a/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs +++ b/OpenNest.Tests/Data/RemoteDrawingLibraryTests.cs @@ -65,6 +65,34 @@ public class RemoteDrawingLibraryTests Assert.Equal(7, page.Total); } + [Fact] + public async Task QueryAsync_SendsOnlyTheFilterLimitsThatAreSet() + { + var handler = new StubHandler(_ => StubHandler.Json(new { items = Array.Empty(), total = 0 })); + using var library = Library(handler); + + await library.QueryAsync(new DrawingLibraryQuery + { + Filter = new DrawingLibraryFilter + { + Customer = "Synthetic Co", + MinThickness = 0.0598, + MaxThickness = 0.1875, + IncludeBlankThickness = false, + MaxLength = 4, + MinHoles = 2, + SavedFrom = new DateOnly(2026, 1, 2), + SavedTo = new DateOnly(2026, 3, 4), + }, + }); + + Assert.Equal( + "http://server:8090/base/api/drawings/query?search=&sort=savedAt&order=desc&offset=0&limit=100" + + "&customer=Synthetic%20Co&minThickness=0.0598&maxThickness=0.1875&includeBlankThickness=false" + + "&maxLength=4&minHoles=2&savedFrom=2026-01-02&savedTo=2026-03-04", + handler.Requests[0].RequestUri!.AbsoluteUri); + } + public static TheoryData OutOfBoundsQueries() => new() { new DrawingLibraryQuery { Offset = -1 }, @@ -76,6 +104,9 @@ public class RemoteDrawingLibraryTests new DrawingLibraryQuery { CutType = (CutType)9 }, new DrawingLibraryQuery { Thickness = double.NaN }, new DrawingLibraryQuery { Thickness = -0.1 }, + new DrawingLibraryQuery { Filter = new DrawingLibraryFilter { MinLength = 5, MaxLength = 4 } }, + new DrawingLibraryQuery { Filter = new DrawingLibraryFilter { MinHoles = -1 } }, + new DrawingLibraryQuery { Filter = null! }, }; [Theory] diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs new file mode 100644 index 00000000..0b4392a9 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFilterFormTests.cs @@ -0,0 +1,119 @@ +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Data; +using OpenNest.Forms; +using static OpenNest.WinForms.Tests.TestReflection; + +namespace OpenNest.WinForms.Tests.Forms; + +public class DrawingLibraryFilterFormTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(30); + + [Fact] + public void ReadsGaugesFractionsAndCounts_AsAnInchFilter() + { + StaTestThread.Run( + () => + { + using var form = new DrawingLibraryFilterForm(DrawingLibraryFilter.None, new[] { "Synthetic Co" }); + Field(form, "customerBox").Text = " Synthetic Co "; + Field(form, "minThicknessBox").Text = "16 ga"; + Field(form, "maxThicknessBox").Text = "3/16"; + Field(form, "blankThicknessCheckBox").Checked = false; + Field(form, "maxLengthBox").Text = "4\""; + Field(form, "minWidthBox").Text = "1-1/2"; + Field(form, "minHolesBox").Text = "2"; + var from = Field(form, "savedFromPicker"); + from.Value = new DateTime(2026, 1, 2); + from.Checked = true; + + Assert.True(form.TryReadFilter(out var filter, out var error), error); + + Assert.Equal(new DrawingLibraryFilter + { + Customer = "Synthetic Co", + MinThickness = 0.0598, + MaxThickness = 0.1875, + IncludeBlankThickness = false, + MaxLength = 4, + MinWidth = 1.5, + MinHoles = 2, + SavedFrom = new DateOnly(2026, 1, 2), + }, filter); + }, + TestTimeout, + "The filter dialog read test did not complete."); + } + + [Theory] + [InlineData("minThicknessBox", "thick")] + [InlineData("maxLengthBox", "16 ga")] + [InlineData("maxLengthBox", "-1")] + [InlineData("minHolesBox", "1.5")] + public void UnreadableBox_KeepsTheDialogOpenWithTheReason(string box, string text) + { + StaTestThread.Run( + () => + { + using var form = new DrawingLibraryFilterForm(DrawingLibraryFilter.None); + form.Show(); + ((Control)typeof(DrawingLibraryFilterForm).GetField(box, System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic)!.GetValue(form)!).Text = text; + + Field