PR Gates / CI image pin matches docker/ci (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 26s
PR Gates / Script tests (pytest) (pull_request) Successful in 46s
Review verdict / Set review-verdict status (pull_request) Successful in 1m6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m32s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 15m45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ 7ca058f (base: main)
Re-review of the round-1 fix commit returned MERGEABLE with three findings, all about claims rather than behaviour. All three applied. 1. A stale FALSE parenthetical survived round 1. The docstring on Unicode_Fold_Agrees_With_The_Ordinal_Filter still claimed it catches "one that stops filtering the extras out". It does not. Mutation-verified: delete the Where in FilterSortTake and all EIGHT cases stay green, because each is either a positive SQL alone returns or an ASCII-query negative SQL alone rejects. The same mutation turns the new over-match test RED, so the pair does cover both directions -- but only the corrected wording says so. This is the same species of error round 1 fixed, one paragraph above it; swept by subject this time. 2. Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter asserts an EMPTY result, so it passes vacuously if the fold never runs. Its premises are now asserted explicitly (the query is non-ASCII, and ToUpperInvariant maps ſ to S), so a fold that quietly stopped mapping ſ would fail rather than go green for the wrong reason. 3. The comment on IsSqlite overstated its enforcement. ProviderStaticsWiringTests parses the composition roots for ASSIGNMENTS only; nothing mechanically stops a read of TvContext.IsSqlite here. The real reason stands -- such a read would falsify that test's prose exemption while the test stayed green -- so the comment now says that instead of implying a guard that does not exist. Decisions-Edit: yes
879 lines
38 KiB
C#
879 lines
38 KiB
C#
using System.Globalization;
|
||
using ErsatzTV.Application.Search.Queries;
|
||
using ErsatzTV.Core.Api.Search;
|
||
using ErsatzTV.Core.Domain;
|
||
using ErsatzTV.Infrastructure.Data;
|
||
using ErsatzTV.Tests.Support;
|
||
using LanguageExt;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using NUnit.Framework;
|
||
using Shouldly;
|
||
|
||
namespace ErsatzTV.Tests.Application.Search;
|
||
|
||
[TestFixture]
|
||
public class GetSearchFieldValuesHandlerTests
|
||
{
|
||
private InMemoryTvContext _db = null!;
|
||
|
||
[SetUp]
|
||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||
|
||
[TearDown]
|
||
public async Task TearDown() => await _db.DisposeAsync();
|
||
|
||
[Test]
|
||
public async Task Returns_Distinct_Whole_Values_For_Genre_With_Prefix_And_Limit()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "Action" },
|
||
new Genre { Name = "Adventure" },
|
||
new Genre { Name = "Animation" },
|
||
new Genre { Name = "Comedy" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", "A", 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action", "Adventure", "Animation" }));
|
||
|
||
Option<SearchFieldValuesResponseModel> limited = await handler.Handle(
|
||
new GetSearchFieldValues("genre", "A", 2),
|
||
CancellationToken.None);
|
||
|
||
limited.IsSome.ShouldBeTrue();
|
||
limited.IfSome(r => r.Values.ShouldBe(new List<string> { "Action", "Adventure" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Splits_Content_Rating_On_Slash()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.MovieMetadata.Add(new MovieMetadata
|
||
{
|
||
MetadataKind = MetadataKind.External,
|
||
DateAdded = DateTime.UtcNow,
|
||
DateUpdated = DateTime.UtcNow,
|
||
ContentRating = "PG-13/TV-14"
|
||
});
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("content_rating", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "PG-13", "TV-14" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Returns_MediaItemState_Enum_Names_Without_Seeding()
|
||
{
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("state", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(
|
||
new List<string> { "FileNotFound", "Normal", "RemoteOnly", "Unavailable" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Returns_NotFound_For_Excluded_Text_Field_Title()
|
||
{
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("title", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsNone.ShouldBeTrue();
|
||
}
|
||
|
||
[Test]
|
||
public async Task Returns_NotFound_For_NonText_Field()
|
||
{
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
// "minutes" is a "number" field in SearchFieldCatalog, not "text"
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("minutes", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsNone.ShouldBeTrue();
|
||
}
|
||
|
||
[Test]
|
||
public async Task Returns_NotFound_For_Unknown_Field()
|
||
{
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("nope", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsNone.ShouldBeTrue();
|
||
}
|
||
|
||
[Test]
|
||
public async Task Matches_Case_Insensitive_Prefix()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "Action" },
|
||
new Genre { Name = "Comedy" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", "a", 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Excludes_Network_And_Country_Tags_From_Tag_Field_And_Routes_Network_Tags_To_Network_Field()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Tag>().AddRange(
|
||
new Tag { Name = "PlainTag" },
|
||
new Tag { Name = "HBO", ExternalTypeId = Tag.PlexNetworkTypeId },
|
||
new Tag { Name = "USA", ExternalTypeId = Tag.NfoCountryTypeId });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> tagResult = await handler.Handle(
|
||
new GetSearchFieldValues("tag", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
tagResult.IsSome.ShouldBeTrue();
|
||
tagResult.IfSome(r => r.Values.ShouldBe(new List<string> { "PlainTag" }));
|
||
|
||
Option<SearchFieldValuesResponseModel> networkResult = await handler.Handle(
|
||
new GetSearchFieldValues("network", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
networkResult.IsSome.ShouldBeTrue();
|
||
networkResult.IfSome(r => r.Values.ShouldBe(new List<string> { "HBO" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Artist_Merges_Entity_Artists_Music_Video_Credits_And_Song_Credits()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.ArtistMetadata.Add(Artist("Alpha Entity"));
|
||
|
||
// negative control: an entity artist that must NOT match the "al" prefix
|
||
context.ArtistMetadata.Add(Artist("Zeta Entity"));
|
||
|
||
context.MusicVideoMetadata.AddRange(
|
||
MusicVideo("MV One", "Alpha Credit", "Alpha Shared"),
|
||
// "Alpha Shared" appears in two rows, so DISTINCT has something to collapse
|
||
MusicVideo("MV Two", "Alpha Shared"),
|
||
MusicVideo("MV Three", "Zeta Credit"));
|
||
|
||
context.SongMetadata.AddRange(
|
||
Song("Song One", ["Alpha Song", "Zeta Song"]),
|
||
Song("Song Two", ["Alpha Song"]),
|
||
Song("Song Three", ["Zeta Only"]));
|
||
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("artist", "al", 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(
|
||
new List<string> { "Alpha Credit", "Alpha Entity", "Alpha Shared", "Alpha Song" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Artist_Returns_Every_Source_For_Empty_Query()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.ArtistMetadata.Add(Artist("Entity"));
|
||
context.MusicVideoMetadata.Add(MusicVideo("MV", "Credit"));
|
||
context.SongMetadata.Add(Song("Song", ["SongArtist"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("artist", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Credit", "Entity", "SongArtist" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Album_Artist_Returns_Song_Album_Artists_Instead_Of_NotFound()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.AddRange(
|
||
Song("One", ["Performer"], ["Alpha Album Artist", "Beta Album Artist"]),
|
||
// repeated across rows so DISTINCT is exercised
|
||
Song("Two", ["Performer"], ["Alpha Album Artist"]),
|
||
// negative control: a row whose album artists are absent entirely
|
||
Song("Three", ["Performer"], null));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("album_artist", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Alpha Album Artist", "Beta Album Artist" }));
|
||
|
||
// the performers on the same rows must not leak into album_artist
|
||
result.IfSome(r => r.Values.ShouldNotContain("Performer"));
|
||
}
|
||
|
||
[Test]
|
||
public async Task List_Valued_Fields_Match_Whole_Elements_Not_Substrings_And_Ignore_Neighbours()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.AddRange(
|
||
// "Neighbour" arrives on the same row as "Radiohead" -- rows are read whole -- and must be
|
||
// dropped by the in-memory exact prefix filter.
|
||
Song("One", ["Radiohead", "Neighbour"]),
|
||
// "The Radio Dept." contains "radio" but does not start with it
|
||
Song("Two", ["The Radio Dept."]),
|
||
Song("Three", ["Radio Birdman"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("artist", "radio", 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Radio Birdman", "Radiohead" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task List_Valued_Fields_Match_Literally_Including_Json_Escaped_And_Wildcard_Characters()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.AddRange(
|
||
// non-ASCII: stored on disk JSON-escaped as \u00E9, and must survive the round trip
|
||
Song("One", ["Beyoncé"]),
|
||
// an embedded quote is stored as \u0022
|
||
Song("Two", ["\"Weird Al\" Yankovic"]),
|
||
// SQL wildcards must be ordinary characters here, matched literally
|
||
Song("Three", ["50% Off"]),
|
||
Song("Four", ["50 Cent"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "beyoncé", 50), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { "Beyoncé" }));
|
||
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "\"weird", 50), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { "\"Weird Al\" Yankovic" }));
|
||
|
||
// "50%" must not behave as the wildcard "50<anything>" — "50 Cent" must not come back
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "50%", 50), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { "50% Off" }));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Seeds <paramref name="fillerRows" /> non-matching songs through raw SQL — 20k rows via the change
|
||
/// tracker is minutes, this is milliseconds.
|
||
/// </summary>
|
||
private static Task SeedFiller(TvContext context, int fillerRows) =>
|
||
context.Database.ExecuteSqlRawAsync(
|
||
$"""
|
||
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < {fillerRows})
|
||
INSERT INTO SongMetadata (SongId, MetadataKind, Title, Artists, DateAdded, DateUpdated)
|
||
SELECT 0, 0, 'Filler ' || n, '["zzz-filler"]', '2026-01-01', '2026-01-01' FROM seq
|
||
""");
|
||
|
||
[Test]
|
||
public async Task List_Valued_Walk_Reads_At_Most_20000_Rows()
|
||
{
|
||
// Pinned in both directions so the ceiling itself is nailed down: a match in row 20000 is read, the same
|
||
// match in row 20001 is not. The query has no RESIDUAL predicate -- only the cursor -- so "rows read" is
|
||
// what LIMIT returns. That bounds LOGICAL rows, not physical work: the engine may still traverse more
|
||
// index records than it returns (MySQL purge lag), and row width is unbounded.
|
||
const string needle = "\u00E9clair-the-needle";
|
||
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
await SeedFiller(context, 19999);
|
||
context.SongMetadata.Add(Song("Needle", [needle]));
|
||
await context.SaveChangesAsync();
|
||
|
||
(await context.SongMetadata.CountAsync()).ShouldBe(20000);
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "\u00E9", 50), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { needle }, "row 20000 is inside the ceiling"));
|
||
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
SongMetadata existing = await context.SongMetadata.SingleAsync(m => m.Title == "Needle");
|
||
context.SongMetadata.Remove(existing);
|
||
await SeedFiller(context, 1);
|
||
await context.SaveChangesAsync();
|
||
context.SongMetadata.Add(Song("Needle", [needle]));
|
||
await context.SaveChangesAsync();
|
||
|
||
(await context.SongMetadata.CountAsync()).ShouldBe(20001);
|
||
}
|
||
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "\u00E9", 50), CancellationToken.None))
|
||
.IfSome(
|
||
r => r.Values.ShouldBeEmpty(
|
||
"row 20001 is past the ceiling; this false negative is the documented bounded-best-effort "
|
||
+ "contract, deliberately pinned rather than papered over"));
|
||
}
|
||
|
||
[Test]
|
||
public async Task List_Valued_Walk_Reads_Live_Rows_Regardless_Of_Id_Density()
|
||
{
|
||
// THE round-4 killer. That revision bounded the Id KEYSPACE, and keyspace is not rows: with 20,000
|
||
// historical rows deleted and one live song at Id 20001, the walk spent its whole allowance on empty
|
||
// ranges and returned [] for a table containing exactly one row. Capacity degraded linearly with
|
||
// deletion ratio, and no ratio was safe -- one placed gap hid the next match.
|
||
//
|
||
// Paging by row position rather than Id value makes density irrelevant: LIMIT @Batch returns @Batch
|
||
// ROWS, wherever they sit in the keyspace.
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
await SeedFiller(context, 20000);
|
||
await context.Database.ExecuteSqlRawAsync("DELETE FROM SongMetadata");
|
||
context.SongMetadata.Add(Song("Survivor", ["Queen"]));
|
||
await context.SaveChangesAsync();
|
||
|
||
// one live row, sitting past the old keyspace allowance
|
||
(await context.SongMetadata.CountAsync()).ShouldBe(1);
|
||
(await context.SongMetadata.Select(m => m.Id).SingleAsync()).ShouldBeGreaterThan(20000);
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "que", 50), CancellationToken.None))
|
||
.IfSome(
|
||
r => r.Values.ShouldBe(
|
||
new List<string> { "Queen" },
|
||
"a one-row table must be fully readable no matter where its Id sits"));
|
||
|
||
// and a leading gap must not hide a later match either
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.Add(Song("Second", ["Queens of the Stone Age"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
(await handler.Handle(new GetSearchFieldValues("artist", "que", 50), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { "Queen", "Queens of the Stone Age" }));
|
||
}
|
||
|
||
[Test]
|
||
[TestCase("é", "\u00C9dith Piaf")]
|
||
[TestCase("\u00C9", "\u00C9dith Piaf")]
|
||
[TestCase("\u00E9dith", "\u00C9dith Piaf")]
|
||
[TestCase("bj", "Bj\u00F6rk")]
|
||
[TestCase("bj\u00F6", "Bj\u00F6rk")]
|
||
[TestCase("BJ\u00D6RK", "Bj\u00F6rk")]
|
||
[TestCase("beyonc\u00E9", "Beyonc\u00E9")]
|
||
[TestCase("sigur r", "Sigur R\u00F3s")]
|
||
[TestCase("\u00D6", "\u00D6zdemir")]
|
||
public async Task Matches_NonAscii_Values_In_Any_Casing(string query, string stored)
|
||
{
|
||
// Accented artists are the common case in a music library, so non-ASCII matching is pinned end to
|
||
// end, in both casings of the query.
|
||
//
|
||
// Historical note, because it is why this suite exists: revision 1b78dc9e narrowed rows in SQL
|
||
// with a LIKE built by JSON-encoding the query, which cannot work -- non-ASCII is stored escaped
|
||
// (\u00C9) and SQL LOWER() folds the escape TEXT, not the codepoint it denotes. THREE of these nine
|
||
// cases fail against that revision (the ones where query and stored casing differ, so \u00e9 and
|
||
// \u00C9 diverge); the other six pass it, because when the casings agree the escape texts line up.
|
||
// The SQL now has no residual predicate at all -- matching happens in memory, where a string is just
|
||
// a string -- so these cases pin current behaviour rather than guard that revision.
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.AddRange(
|
||
Song("Hit", [stored]),
|
||
// negative control: a row that must never come back for any of these queries
|
||
Song("Other", ["Nothing Relevant"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("artist", query, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { stored }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task Results_Do_Not_Depend_On_The_Request_Culture()
|
||
{
|
||
// UseRequestLocalization honours Accept-Language, so CurrentCulture is caller-controlled. Under tr-TR
|
||
// the old `q.ToLower()` turned "I" into "\u0131" and the default linguistic StartsWith(string) compounded
|
||
// it, so the same library answered differently per caller. The contract is ordinal: "I" matches
|
||
// "Istanbul" and does NOT match "\u0131pek", in every culture.
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.Add(Song("One", ["Istanbul Orkestrasi", "\u0131pek"]));
|
||
context.ArtistMetadata.Add(Artist("Idil Biret"));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
var expected = new List<string> { "Idil Biret", "Istanbul Orkestrasi" };
|
||
|
||
CultureInfo original = CultureInfo.CurrentCulture;
|
||
try
|
||
{
|
||
foreach (string culture in new[] { "en-US", "tr-TR", "az-AZ", "lt-LT" })
|
||
{
|
||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("artist", "I", 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(expected, $"culture {culture} changed the result"));
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
CultureInfo.CurrentCulture = original;
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task Ordering_Is_Ordinal_And_Culture_Independent()
|
||
{
|
||
// The merge sorts ordinally rather than by culture, so the response order does not depend on the caller
|
||
// either. Ordinal puts all ASCII uppercase before ASCII lowercase, and non-ASCII last.
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.Add(Song("One", ["Zulu", "apple", "\u00C9clair", "Apple"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
var expected = new List<string> { "Apple", "Zulu", "apple", "\u00C9clair" };
|
||
|
||
CultureInfo original = CultureInfo.CurrentCulture;
|
||
try
|
||
{
|
||
foreach (string culture in new[] { "en-US", "sv-SE" })
|
||
{
|
||
CultureInfo.CurrentCulture = new CultureInfo(culture);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("artist", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IfSome(r => r.Values.ShouldBe(expected, $"culture {culture} changed the order"));
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
CultureInfo.CurrentCulture = original;
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task Ordering_Is_Best_Effort_When_A_Source_Truncates()
|
||
{
|
||
// Documents the acknowledged imprecision rather than claiming exactness the code does not have. The EF
|
||
// source truncates by the DATABASE collation, which is NOT the ordinal ordering the merge then applies —
|
||
// so a value the database ranked outside its first `limit` never reaches the merge, even if the merge
|
||
// would have ranked it first.
|
||
//
|
||
// "Zulu" vs "apple" is the pair that actually diverges: ordinal puts every ASCII uppercase letter before
|
||
// every lowercase one, so ordinal ranks "Zulu" first, while a case-insensitive database ordering ranks
|
||
// "apple" first. (An earlier version used "Zulu"/"Éclair", where BOTH orderings pick "Zulu" — it could
|
||
// not have told the two apart, and the divergence it claimed to show did not exist.)
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.ArtistMetadata.Add(Artist("Zulu"));
|
||
context.ArtistMetadata.Add(Artist("apple"));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
// with room for both, the ordinal merge ranks "Zulu" first
|
||
(await handler.Handle(new GetSearchFieldValues("artist", string.Empty, 50), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { "Zulu", "apple" }));
|
||
|
||
// with limit=1 the database picks the survivor by ITS ordering, and the merge only ever sees that one
|
||
(await handler.Handle(new GetSearchFieldValues("artist", string.Empty, 1), CancellationToken.None))
|
||
.IfSome(r => r.Values.ShouldBe(new List<string> { "apple" }));
|
||
}
|
||
|
||
[Test]
|
||
public async Task A_Match_Behind_Many_NonMatching_Rows_Is_Still_Found()
|
||
{
|
||
// Fails a883e5f0, which capped rows at a fixed 1000 AFTER a deliberately over-matching SQL pre-filter:
|
||
// the 1001st row -- the only exact match -- was discarded before the in-memory filter ever saw it and
|
||
// the endpoint returned []. The pre-filter is gone, and the property it broke now holds for any match
|
||
// within the read ceiling: preceding non-matching rows do not hide it. Past the ceiling it is still
|
||
// lost by design -- see List_Valued_Walk_Reads_At_Most_20000_Rows, which pins that boundary.
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
context.SongMetadata.Add(Song($"Filler {i}", ["zzz-filler"], ["zzz-filler-album"]));
|
||
}
|
||
|
||
context.SongMetadata.Add(Song("Needle", ["\u00E9clair"], ["\u00E9clair"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> albumArtist = await handler.Handle(
|
||
new GetSearchFieldValues("album_artist", "\u00E9", 50),
|
||
CancellationToken.None);
|
||
|
||
albumArtist.IsSome.ShouldBeTrue();
|
||
albumArtist.IfSome(r => r.Values.ShouldBe(new List<string> { "\u00E9clair" }));
|
||
|
||
// same starvation shape on the merged `artist` field
|
||
Option<SearchFieldValuesResponseModel> artist = await handler.Handle(
|
||
new GetSearchFieldValues("artist", "\u00E9", 50),
|
||
CancellationToken.None);
|
||
|
||
artist.IsSome.ShouldBeTrue();
|
||
artist.IfSome(r => r.Values.ShouldBe(new List<string> { "\u00E9clair" }));
|
||
|
||
// ... and for a prefix beginning with a character that JSON escapes on disk. That used to collapse the
|
||
// SQL pattern to the bare anchor; there is no prefix predicate at all now, so it is simply an ordinary
|
||
// prefix -- kept because it is the input shape that broke the old scheme.
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.SongMetadata.Add(Song("Ampersand", ["&Me"]));
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
Option<SearchFieldValuesResponseModel> escapedPrefix = await handler.Handle(
|
||
new GetSearchFieldValues("artist", "&M", 50),
|
||
CancellationToken.None);
|
||
|
||
escapedPrefix.IsSome.ShouldBeTrue();
|
||
escapedPrefix.IfSome(r => r.Values.ShouldBe(new List<string> { "&Me" }));
|
||
}
|
||
|
||
private static ArtistMetadata Artist(string title) => new()
|
||
{
|
||
MetadataKind = MetadataKind.External,
|
||
DateAdded = DateTime.UtcNow,
|
||
DateUpdated = DateTime.UtcNow,
|
||
Title = title
|
||
};
|
||
|
||
private static MusicVideoMetadata MusicVideo(string title, params string[] artists) => new()
|
||
{
|
||
MetadataKind = MetadataKind.External,
|
||
DateAdded = DateTime.UtcNow,
|
||
DateUpdated = DateTime.UtcNow,
|
||
Title = title,
|
||
Artists = artists.Map(a => new MusicVideoArtist { Name = a }).ToList()
|
||
};
|
||
|
||
private static SongMetadata Song(string title, IList<string> artists, IList<string> albumArtists = null) => new()
|
||
{
|
||
MetadataKind = MetadataKind.External,
|
||
DateAdded = DateTime.UtcNow,
|
||
DateUpdated = DateTime.UtcNow,
|
||
Title = title,
|
||
Artists = artists,
|
||
AlbumArtists = albumArtists
|
||
};
|
||
|
||
[Test]
|
||
public async Task Dedupes_Repeated_Values()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "Action" },
|
||
new Genre { Name = "Action" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", string.Empty, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Action" }));
|
||
}
|
||
|
||
/// <summary>
|
||
/// ersatztv#668. The EF-sourced fields prefix-match through SQL <c>LOWER()</c>, which on SQLite folds
|
||
/// ASCII only: <c>lower('Édith')</c> returns <c>'Édith'</c> unchanged, so a stored value whose
|
||
/// prefix carries an uppercase non-ASCII character is unreachable from any query long enough to reach it.
|
||
/// The stored-LOWERCASE case already worked (the handler lowercases the query before it reaches SQL, so
|
||
/// both casings of the query fold to the same pattern) and is pinned alongside it, because the fix must
|
||
/// SUPPLEMENT that path rather than replace it.
|
||
/// </summary>
|
||
[TestCase("genre", "é")]
|
||
[TestCase("genre", "É")]
|
||
public async Task Ef_Sourced_Stored_Uppercase_Accent_Is_Reachable(string field, string query)
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "Édith" },
|
||
new Genre { Name = "Zulu" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues(field, query, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "Édith" }));
|
||
}
|
||
|
||
/// <inheritdoc cref="Ef_Sourced_Stored_Uppercase_Accent_Is_Reachable" />
|
||
[TestCase("genre", "é")]
|
||
[TestCase("genre", "É")]
|
||
public async Task Ef_Sourced_Stored_Lowercase_Accent_Stays_Reachable(string field, string query)
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "édith" },
|
||
new Genre { Name = "Zulu" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues(field, query, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "édith" }));
|
||
}
|
||
|
||
/// <summary>
|
||
/// ersatztv#668. The Unicode fold added for the non-ASCII branch may OVER-match — the in-memory
|
||
/// <see cref="StringComparison.OrdinalIgnoreCase" /> filter runs afterwards and drops the extras —
|
||
/// but it must never UNDER-match. Each case pins the endpoint's answer against what that filter
|
||
/// alone would say, so a fold that starts dropping rows fails here. It does NOT catch removal of the
|
||
/// in-memory filter — every case here is either a positive that SQL alone returns, or an ASCII-query
|
||
/// negative that SQL alone rejects. That direction is
|
||
/// <see cref="Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter" />'s job.
|
||
/// <para>
|
||
/// The negative cases here have ASCII queries, so they exercise the FAST PATH (the fold is
|
||
/// skipped entirely) and pin that it is exact: <c>"ſweet".StartsWith("S", OrdinalIgnoreCase)</c>
|
||
/// is false even though <c>char.ToUpperInvariant('ſ')</c> IS <c>'S'</c>. The over-match the fold
|
||
/// itself produces is a different path and is covered by
|
||
/// <see cref="Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter" />.
|
||
/// </para>
|
||
/// </summary>
|
||
[TestCase("Édith", "é", true, TestName = "Fold_UppercaseAccent_LowercaseQuery")]
|
||
[TestCase("Édith", "É", true, TestName = "Fold_UppercaseAccent_UppercaseQuery")]
|
||
[TestCase("Özdemir", "ö", true, TestName = "Fold_Umlaut")]
|
||
[TestCase("Sigur Rós", "sigur", true, TestName = "Fold_AsciiPrefix_NonAsciiLater")]
|
||
[TestCase("Straße", "stra", true, TestName = "Fold_Eszett_AsciiQuery")]
|
||
// explicit escapes: these three are visually indistinguishable from their ASCII lookalikes in a diff,
|
||
// and an ASCII 'K' here would silently turn the KELVIN SIGN case into a trivially-true one
|
||
[TestCase("\u017Fweet", "S", false, TestName = "Fold_LongS_IsNotOrdinalEqualToS")]
|
||
[TestCase("\u212Aelvin", "k", false, TestName = "Fold_KelvinSign_IsNotOrdinalEqualToK")]
|
||
[TestCase("\u0130stanbul", "i", false, TestName = "Fold_DottedCapitalI_IsNotOrdinalEqualToI")]
|
||
public async Task Unicode_Fold_Agrees_With_The_Ordinal_Filter(string stored, string query, bool expected)
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().Add(new Genre { Name = stored });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
// the oracle: what the endpoint's own final filter says, computed independently of the database
|
||
stored.StartsWith(query, StringComparison.OrdinalIgnoreCase).ShouldBe(expected);
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", query, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(expected ? new List<string> { stored } : []));
|
||
}
|
||
|
||
/// <summary>
|
||
/// ersatztv#668. Drives a row THROUGH the fold that the ordinal filter must then discard — the
|
||
/// harmless over-match direction the whole design rests on, which the ASCII-query negative cases
|
||
/// above cannot reach. q="ſ" is non-ASCII so the fold runs; <c>ToUpperInvariant('ſ')</c> is 'S', so
|
||
/// the SQL pattern is <c>S%</c> and SQLite genuinely returns "Sword" — and the response must still
|
||
/// be empty, because <c>"Sword".StartsWith("ſ", OrdinalIgnoreCase)</c> is false.
|
||
/// </summary>
|
||
[Test]
|
||
public async Task Unicode_Fold_Over_Match_Is_Discarded_By_The_Ordinal_Filter()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().Add(new Genre { Name = "Sword" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
// Premises, asserted because the expectation is an EMPTY list and would otherwise pass for the
|
||
// wrong reason -- e.g. if the branch stopped running, or a hand-rolled fold stopped mapping ſ to S,
|
||
// SQL would return nothing and this test would still be green.
|
||
GetSearchFieldValuesHandler.ContainsNonAscii("\u017F").ShouldBeTrue();
|
||
char.ToUpperInvariant('\u017F').ShouldBe('S');
|
||
"Sword".StartsWith("\u017F", StringComparison.OrdinalIgnoreCase).ShouldBeFalse();
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", "\u017F", 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBeEmpty());
|
||
}
|
||
|
||
/// <summary>
|
||
/// ersatztv#668. The escaping's load-bearing role is NOT filtering — the in-memory ordinal filter
|
||
/// already drops an over-match, which is why a plain count assertion stays green even with the
|
||
/// escaping removed. It is preventing LIMIT CROWDING: an unescaped <c>_</c> also matches the space,
|
||
/// binary ORDER BY ranks "100 Édith" first, LIMIT 1 returns only that, the filter discards it, and
|
||
/// the genuine "100_Édith" is never returned at all. This case fails if the escaping is removed.
|
||
/// </summary>
|
||
[Test]
|
||
public async Task Unicode_Fold_Escaping_Prevents_Limit_Crowding()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "100 \u00C9dith" },
|
||
new Genre { Name = "100_\u00C9dith" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", "100_\u00C9", 1),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.ShouldBe(new List<string> { "100_\u00C9dith" }));
|
||
}
|
||
|
||
/// <summary>
|
||
/// ersatztv#668. The non-ASCII branch is raw SQL, so it gets none of the LIKE-wildcard escaping EF
|
||
/// does for <c>StartsWith</c>. An unescaped <c>%</c> or <c>_</c> in the query would match anything.
|
||
/// </summary>
|
||
[TestCase("100%É", 1, TestName = "Escapes_Percent")]
|
||
[TestCase("100_É", 0, TestName = "Escapes_Underscore")]
|
||
public async Task Unicode_Fold_Escapes_Like_Wildcards(string query, int expectedCount)
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Genre>().AddRange(
|
||
new Genre { Name = "100%Édith" },
|
||
new Genre { Name = "100XÉdith" });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> result = await handler.Handle(
|
||
new GetSearchFieldValues("genre", query, 50),
|
||
CancellationToken.None);
|
||
|
||
result.IsSome.ShouldBeTrue();
|
||
result.IfSome(r => r.Values.Count.ShouldBe(expectedCount));
|
||
}
|
||
|
||
/// <summary>
|
||
/// ersatztv#668. The non-ASCII branch duplicates each field's discriminator predicate in raw SQL, so
|
||
/// it must reproduce EF's NULL semantics: EF compiles <c>ExternalTypeId != NfoCountryTypeId</c> with
|
||
/// null semantics, which INCLUDES a NULL-typed row. Plain SQL <c><></c> would silently drop it.
|
||
/// </summary>
|
||
[Test]
|
||
public async Task Unicode_Fold_Tag_Discriminator_Matches_Ef_Null_Semantics()
|
||
{
|
||
await using (TvContext context = _db.CreateContext())
|
||
{
|
||
context.Set<Tag>().AddRange(
|
||
new Tag { Name = "Édith", ExternalTypeId = null },
|
||
new Tag { Name = "Éclair", ExternalTypeId = Tag.PlexNetworkTypeId },
|
||
new Tag { Name = "Ézra", ExternalTypeId = Tag.NfoCountryTypeId });
|
||
await context.SaveChangesAsync();
|
||
}
|
||
|
||
var handler = new GetSearchFieldValuesHandler(_db.Factory);
|
||
|
||
Option<SearchFieldValuesResponseModel> tags = await handler.Handle(
|
||
new GetSearchFieldValues("tag", "é", 50),
|
||
CancellationToken.None);
|
||
|
||
// the NULL-typed row is a tag; the network- and country-typed rows are excluded
|
||
tags.IsSome.ShouldBeTrue();
|
||
tags.IfSome(r => r.Values.ShouldBe(new List<string> { "Édith" }));
|
||
|
||
Option<SearchFieldValuesResponseModel> networks = await handler.Handle(
|
||
new GetSearchFieldValues("network", "é", 50),
|
||
CancellationToken.None);
|
||
|
||
networks.IsSome.ShouldBeTrue();
|
||
networks.IfSome(r => r.Values.ShouldBe(new List<string> { "Éclair" }));
|
||
}
|
||
}
|