Files
OpenNest/OpenNest.Tests/Data/DrawingLibraryBrowseSessionTests.cs
T
ajandClaude Opus 5.5 175782d8f8 feat(data): library browse session
Add from Drawing Library needs the same race-safe paging as the Database-mode
Open dialog: a generation counter so an older response never replaces a newer
one, cancellation after the superseding request is installed, and IsLoading
tracking only the latest request. `DrawingLibraryBrowseSession` mirrors
`NestBrowseSession` for library queries.

It starts filtered to the nest's material, grade and thickness, since that is
almost always what the operator wants and a mismatch drops cut pins anyway
(D3). "Show all" lifts the filter. A nest with no thickness filters by material
only, and the empty-result text tells the operator when the nest filter, rather
than an empty library, is why nothing is listed. Archived drawings stay hidden.

`IDrawingLibrary` is the read side the session and the add flow use, so both
are testable with fakes; `RemoteDrawingLibrary` implements it.

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

274 lines
11 KiB
C#

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<ServerTooOldException>(() => 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<ObjectDisposedException>(() => session.RefreshAsync());
}
[Theory]
[InlineData(0)]
[InlineData(DrawingLibraryQuery.MaxLimit + 1)]
public void Constructor_RejectsPageSizesTheServerWouldRefuse(int pageSize)
{
Assert.Throws<ArgumentOutOfRangeException>(() => 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 };
/// <summary>Serves pages of an in-memory set (ignoring filters) and records each query.</summary>
private sealed class PagedLibrary : IDrawingLibrary
{
private readonly List<DrawingLibraryRecord> _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<DrawingLibraryQuery> Queries { get; } = new();
public void RemoveLast() => _records.RemoveAt(_records.Count - 1);
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
{
Assert.Null(query.GetValidationError());
Queries.Add(query);
var matches = query.NormalizedSearch.Length == 0 ? _records : new List<DrawingLibraryRecord>();
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<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
/// <summary>
/// Leaves every query pending until the test completes it. With
/// <see cref="CompleteOnCancellation"/>, cancellation completes the query synchronously
/// inside the canceller's call, as some HTTP handlers do.
/// </summary>
private sealed class ControlledLibrary : IDrawingLibrary
{
public bool CompleteOnCancellation { get; init; }
public List<(DrawingLibraryQuery Query, TaskCompletionSource<DrawingLibraryPage> Reply, CancellationToken Token)> Requests { get; } = new();
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
{
var reply = new TaskCompletionSource<DrawingLibraryPage>();
if (CompleteOnCancellation)
cancellationToken.Register(() => reply.TrySetCanceled(cancellationToken));
Requests.Add((query, reply, cancellationToken));
return reply.Task;
}
public Task<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}