diff --git a/OpenNest.Data/DrawingLibraryBrowseSession.cs b/OpenNest.Data/DrawingLibraryBrowseSession.cs new file mode 100644 index 00000000..160dba0c --- /dev/null +++ b/OpenNest.Data/DrawingLibraryBrowseSession.cs @@ -0,0 +1,230 @@ +using System.Globalization; +using OpenNest; + +namespace OpenNest.Data; + +/// +/// Browse state for Add from Drawing Library: the requested search, sort, page and +/// nest filter, and the last page applied. By default only drawings with the nest's +/// material, grade and thickness are listed; lifts that +/// filter. Every change issues one bounded server query. A response that a later request +/// superseded is discarded (and the earlier request is cancelled), so an older result can +/// never replace a newer one. Call from one thread (the UI thread). +/// +public sealed class DrawingLibraryBrowseSession : IDisposable +{ + private readonly IDrawingLibrary _library; + private CancellationTokenSource? _pending; + private int _generation; + private bool _disposed; + + public DrawingLibraryBrowseSession(IDrawingLibrary library, Nest nest, int pageSize = DrawingLibraryQuery.DefaultLimit) + { + ArgumentNullException.ThrowIfNull(library); + ArgumentNullException.ThrowIfNull(nest); + if (pageSize < 1 || pageSize > DrawingLibraryQuery.MaxLimit) + throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, $"Page size must be 1 to {DrawingLibraryQuery.MaxLimit}."); + + _library = library; + PageSize = pageSize; + MaterialName = (nest.Material?.Name ?? "").Trim(); + MaterialGrade = (nest.Material?.Grade ?? "").Trim(); + // A nest without a thickness has nothing to match; it filters by material only. + Thickness = double.IsFinite(nest.Thickness) && nest.Thickness > 0 + ? DrawingLibraryRules.NormalizeThickness(nest.Thickness) + : null; + } + + public int PageSize { get; } + + /// The nest's material name, applied unless ; blank means any. + public string MaterialName { get; } + + /// The nest's grade, applied unless ; blank means any. + public string MaterialGrade { get; } + + /// The nest's thickness, applied unless ; null means any. + public double? Thickness { get; } + + /// True when the nest filter is lifted and every active drawing is listed. + public bool ShowAll { get; private set; } + + /// 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); + + /// Trimmed search text of the latest request. + public string Search { get; private set; } = ""; + + public DrawingSortField Sort { get; private set; } = DrawingSortField.SavedAt; + + public bool Descending { get; private set; } = true; + + /// Offset of the latest request. + public int Offset { get; private set; } + + /// The latest applied page; null before the first result and after a failed request. + public DrawingLibraryPage? Page { get; private set; } + + /// True while the latest request is outstanding; superseded requests do not count. + public bool IsLoading { get; private set; } + + public bool CanGoPrevious => Offset > 0; + + public bool CanGoNext => Page is { } page && page.Offset + page.Items.Count < page.Total; + + /// Operator-facing summary of , such as "Showing 101-200 of 1,234 drawings". + public string Summary + { + get + { + if (Page is not { } page) + return ""; + if (page.Total == 0) + { + if (Search.Length > 0) + return "No drawings match the search."; + return IsFilteredToNest + ? "No library drawings match this nest's material and thickness. Choose Show all to see every drawing." + : "No drawings in the library."; + } + if (page.Items.Count == 0) + return $"No drawings on this page ({Count(page.Total)} in total)."; + + return $"Showing {Count(page.Offset + 1)}-{Count(page.Offset + page.Items.Count)} of {Count(page.Total)} drawings"; + } + } + + /// Applies new search text and returns to the first page. + public Task SetSearchAsync(string? search) + { + Search = (search ?? "").Trim(); + Offset = 0; + return RunAsync(); + } + + /// Lifts or restores the nest filter and returns to the first page. + public Task SetShowAllAsync(bool showAll) + { + ShowAll = showAll; + Offset = 0; + return RunAsync(); + } + + /// Sorts by a column: a new column starts ascending, the same column reverses. + public Task SortByAsync(DrawingSortField field) + { + Descending = field == Sort && !Descending; + Sort = field; + Offset = 0; + return RunAsync(); + } + + public Task NextPageAsync() + { + if (!CanGoNext) + return Task.FromResult(false); + + Offset = Page!.Offset + PageSize; + return RunAsync(); + } + + public Task PreviousPageAsync() + { + if (!CanGoPrevious) + return Task.FromResult(false); + + Offset = System.Math.Max(0, Offset - PageSize); + return RunAsync(); + } + + /// Re-reads the current page; steps back to the last page if it is now past the end. + public Task RefreshAsync() => RunAsync(); + + /// + /// Sends the current request. Returns true when its page was applied, false when a later + /// request superseded it. Failures of the latest request clear and propagate; + /// failures of superseded requests are ignored. + /// + private async Task RunAsync() + { + ObjectDisposedException.ThrowIf(_disposed, this); + // Supersede before cancelling: a request that completes synchronously when cancelled + // must already see itself as superseded. Each source is released by its own request. + var previous = _pending; + using var cancellation = new CancellationTokenSource(); + _pending = cancellation; + var generation = ++_generation; + IsLoading = true; + + try + { + previous?.Cancel(); + var page = await QueryAsync(cancellation.Token); + if (generation != _generation) + return false; + + // An archive or another PC can leave the requested page past the end. + if (page.Items.Count == 0 && page.Offset > 0 && page.Total > 0) + { + Offset = (page.Total - 1) / PageSize * PageSize; + page = await QueryAsync(cancellation.Token); + if (generation != _generation) + return false; + } + + Page = page; + return true; + } + catch (Exception) when (generation != _generation) + { + return false; + } + catch + { + Page = null; + throw; + } + finally + { + if (ReferenceEquals(_pending, cancellation)) + _pending = null; + if (generation == _generation) + IsLoading = false; + } + } + + private Task QueryAsync(CancellationToken cancellationToken) => + _library.QueryAsync( + new DrawingLibraryQuery + { + Search = Search, + MaterialName = ShowAll ? "" : MaterialName, + MaterialGrade = ShowAll ? "" : MaterialGrade, + Thickness = ShowAll ? null : Thickness, + Sort = Sort, + Descending = Descending, + Offset = Offset, + Limit = PageSize, + }, + cancellationToken); + + private static string Count(int value) => value.ToString("N0", CultureInfo.CurrentCulture); + + /// + /// Cancels any request in flight (which releases its own source when it ends); + /// the library client is not owned and stays open. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _generation++; + IsLoading = false; + var pending = _pending; + _pending = null; + pending?.Cancel(); + } +} diff --git a/OpenNest.Data/IDrawingLibrary.cs b/OpenNest.Data/IDrawingLibrary.cs new file mode 100644 index 00000000..033860a8 --- /dev/null +++ b/OpenNest.Data/IDrawingLibrary.cs @@ -0,0 +1,14 @@ +namespace OpenNest.Data; + +/// +/// The read side of the server drawing library that browsing and adding to a nest use. +/// is the only production implementation. +/// +public interface IDrawingLibrary +{ + /// One bounded page of drawings matching the query, filtered and ordered by the server. + Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default); + + /// The exact package of one immutable revision, or null when the drawing or revision is unknown. + Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default); +} diff --git a/OpenNest.Data/RemoteDrawingLibrary.cs b/OpenNest.Data/RemoteDrawingLibrary.cs index 0f0d5da1..8b1571e5 100644 --- a/OpenNest.Data/RemoteDrawingLibrary.cs +++ b/OpenNest.Data/RemoteDrawingLibrary.cs @@ -16,7 +16,7 @@ namespace OpenNest.Data; /// the library raises ; this client never falls back /// to another store. Other failures surface as . /// -public sealed class RemoteDrawingLibrary : IDisposable +public sealed class RemoteDrawingLibrary : IDrawingLibrary, IDisposable { private static readonly JsonSerializerOptions JsonOptions = new() { diff --git a/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs new file mode 100644 index 00000000..825a94bb --- /dev/null +++ b/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs @@ -0,0 +1,273 @@ +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class DrawingLibraryBrowseSessionTests +{ + private static Nest SyntheticNest() => new("Synthetic job") + { + Thickness = 0.187504, + Material = new Material(" Synthetic Steel ", "Grade A"), + }; + + [Fact] + public async Task Refresh_DefaultsToTheNestsMaterialGradeAndThicknessNewestSavedFirst() + { + var library = new PagedLibrary(25); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10); + + Assert.True(await session.RefreshAsync()); + + var query = Assert.Single(library.Queries); + Assert.Equal(("Synthetic Steel", "Grade A", (double?)0.1875), (query.MaterialName, query.MaterialGrade, query.Thickness)); + Assert.Equal(("", (CutType?)null, false), (query.Search, query.CutType, query.IncludeArchived)); + Assert.Equal((DrawingSortField.SavedAt, true, 0, 10), (query.Sort, query.Descending, query.Offset, query.Limit)); + Assert.True(session.IsFilteredToNest); + Assert.Equal("Showing 1-10 of 25 drawings", session.Summary); + } + + [Fact] + public async Task ShowAll_LiftsTheNestFilterAndReturnsToTheFirstPage() + { + var library = new PagedLibrary(25); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + + await session.SetShowAllAsync(true); + + var all = library.Queries[^1]; + Assert.Equal(("", "", (double?)null, 0), (all.MaterialName, all.MaterialGrade, all.Thickness, all.Offset)); + Assert.False(session.IsFilteredToNest); + + await session.SetShowAllAsync(false); + + var filtered = library.Queries[^1]; + Assert.Equal(("Synthetic Steel", "Grade A", (double?)0.1875), (filtered.MaterialName, filtered.MaterialGrade, filtered.Thickness)); + } + + [Fact] + public async Task NestWithoutMaterialOrThickness_IsNotFiltered() + { + var library = new PagedLibrary(3); + using var session = new DrawingLibraryBrowseSession(library, new Nest("Blank job") { Material = null! }); + + await session.RefreshAsync(); + + var query = Assert.Single(library.Queries); + Assert.Equal(("", "", (double?)null), (query.MaterialName, query.MaterialGrade, query.Thickness)); + Assert.False(session.IsFilteredToNest); + } + + [Fact] + public async Task SearchSortAndPaging_FollowTheNestBrowseRules() + { + var library = new PagedLibrary(25); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10); + await session.RefreshAsync(); + Assert.False(session.CanGoPrevious); + + Assert.True(await session.NextPageAsync()); + Assert.True(await session.NextPageAsync()); + Assert.Equal("Showing 21-25 of 25 drawings", session.Summary); + Assert.False(session.CanGoNext); + var sent = library.Queries.Count; + Assert.False(await session.NextPageAsync()); + Assert.Equal(sent, library.Queries.Count); + Assert.True(await session.PreviousPageAsync()); + Assert.Equal(10, library.Queries[^1].Offset); + + await session.SetSearchAsync(" bracket "); + Assert.Equal(("bracket", 0), (library.Queries[^1].Search, library.Queries[^1].Offset)); + + await session.SortByAsync(DrawingSortField.Name); + Assert.Equal((DrawingSortField.Name, false), (library.Queries[^1].Sort, library.Queries[^1].Descending)); + await session.SortByAsync(DrawingSortField.Name); + Assert.Equal((DrawingSortField.Name, true), (library.Queries[^1].Sort, library.Queries[^1].Descending)); + } + + [Theory] + [InlineData(false, "", "No library drawings match this nest's material and thickness. Choose Show all to see every drawing.")] + [InlineData(true, "", "No drawings in the library.")] + [InlineData(false, "no such text", "No drawings match the search.")] + public async Task Summary_ExplainsAnEmptyResult(bool showAll, string search, string expected) + { + using var session = new DrawingLibraryBrowseSession(new PagedLibrary(0), SyntheticNest()); + + await session.SetShowAllAsync(showAll); + await session.SetSearchAsync(search); + + Assert.Equal(expected, session.Summary); + } + + [Fact] + public async Task OlderResponse_ArrivingLast_NeverReplacesTheNewerResult() + { + var library = new ControlledLibrary(); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest()); + var older = session.SetSearchAsync("a"); + var newer = session.SetShowAllAsync(true); + var newerPage = Page("show all result"); + + library.Requests[1].Reply.SetResult(newerPage); + Assert.True(await newer); + library.Requests[0].Reply.SetResult(Page("a result")); + Assert.False(await older); + + Assert.Same(newerPage, session.Page); + Assert.True(library.Requests[0].Token.IsCancellationRequested); + Assert.False(library.Requests[1].Token.IsCancellationRequested); + } + + [Fact] + public async Task SupersededFailure_IsIgnored_LatestFailure_ClearsThePageAndPropagates() + { + var library = new ControlledLibrary(); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest()); + var older = session.SetSearchAsync("a"); + var newer = session.SetSearchAsync("ab"); + + library.Requests[0].Reply.SetException(new IOException("superseded failure")); + Assert.False(await older); + library.Requests[1].Reply.SetResult(Page("ab result")); + Assert.True(await newer); + + var failing = session.RefreshAsync(); + library.Requests[2].Reply.SetException(new ServerTooOldException()); + await Assert.ThrowsAsync(() => failing); + + Assert.Null(session.Page); + Assert.Equal("", session.Summary); + Assert.False(session.IsLoading); + } + + [Fact] + public async Task SupersededRequest_CompletingSynchronouslyOnCancellation_IsNotReportedAsTheLatestFailure() + { + var library = new ControlledLibrary { CompleteOnCancellation = true }; + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest()); + var older = session.SetSearchAsync("a"); + var newer = session.SetSearchAsync("ab"); + + Assert.False(await older); + library.Requests[1].Reply.SetResult(Page("ab result")); + Assert.True(await newer); + + Assert.Equal("ab result", Assert.Single(session.Page!.Items).Name); + } + + [Fact] + public async Task IsLoading_FollowsOnlyTheLatestRequest() + { + var library = new ControlledLibrary(); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest()); + + var first = session.SetSearchAsync("a"); + var second = session.SetSearchAsync("ab"); + library.Requests[0].Reply.SetResult(Page("a result")); + Assert.False(await first); + Assert.True(session.IsLoading); + library.Requests[1].Reply.SetResult(Page("ab result")); + Assert.True(await second); + Assert.False(session.IsLoading); + } + + [Fact] + public async Task Refresh_AfterTheLastPageWasEmptied_StepsBackToTheNewLastPage() + { + var library = new PagedLibrary(21); + using var session = new DrawingLibraryBrowseSession(library, SyntheticNest(), pageSize: 10); + await session.RefreshAsync(); + await session.NextPageAsync(); + await session.NextPageAsync(); + + library.RemoveLast(); + Assert.True(await session.RefreshAsync()); + + Assert.Equal(new[] { 20, 10 }, library.Queries.TakeLast(2).Select(q => q.Offset)); + Assert.Equal("Showing 11-20 of 20 drawings", session.Summary); + } + + [Fact] + public async Task Dispose_CancelsTheRequestAndDiscardsItsLateResponse() + { + var library = new ControlledLibrary(); + var session = new DrawingLibraryBrowseSession(library, SyntheticNest()); + var pending = session.SetSearchAsync("a"); + + session.Dispose(); + library.Requests[0].Reply.SetResult(Page("late")); + + Assert.True(library.Requests[0].Token.IsCancellationRequested); + Assert.False(await pending); + Assert.Null(session.Page); + await Assert.ThrowsAsync(() => session.RefreshAsync()); + } + + [Theory] + [InlineData(0)] + [InlineData(DrawingLibraryQuery.MaxLimit + 1)] + public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize) + { + Assert.Throws(() => new DrawingLibraryBrowseSession(new PagedLibrary(0), SyntheticNest(), pageSize)); + } + + private static DrawingLibraryPage Page(string name) => + new() { Items = new[] { new DrawingLibraryRecord { Id = Guid.NewGuid(), Revision = 1, Name = name } }, Total = 1, Limit = 100 }; + + /// Serves pages of an in-memory set (ignoring filters) and records each query. + private sealed class PagedLibrary : IDrawingLibrary + { + private readonly List _records; + + public PagedLibrary(int count) => + _records = Enumerable.Range(0, count) + .Select(i => new DrawingLibraryRecord { Id = Guid.NewGuid(), Revision = 1, Name = $"Drawing {i}" }) + .ToList(); + + public List Queries { get; } = new(); + + public void RemoveLast() => _records.RemoveAt(_records.Count - 1); + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) + { + Assert.Null(query.GetValidationError()); + Queries.Add(query); + var matches = query.NormalizedSearch.Length == 0 ? _records : new List(); + return Task.FromResult(new DrawingLibraryPage + { + Items = matches.Skip(query.Offset).Take(query.Limit).ToArray(), + Total = matches.Count, + Offset = query.Offset, + Limit = query.Limit, + }); + } + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } + + /// + /// Leaves every query pending until the test completes it. With + /// , cancellation completes the query synchronously + /// inside the canceller's call, as some HTTP handlers do. + /// + private sealed class ControlledLibrary : IDrawingLibrary + { + public bool CompleteOnCancellation { get; init; } + + public List<(DrawingLibraryQuery Query, TaskCompletionSource Reply, CancellationToken Token)> Requests { get; } = new(); + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) + { + var reply = new TaskCompletionSource(); + if (CompleteOnCancellation) + cancellationToken.Register(() => reply.TrySetCanceled(cancellationToken)); + Requests.Add((query, reply, cancellationToken)); + return reply.Task; + } + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) => + throw new NotSupportedException(); + } +}