Files
ersatztv/ErsatzTV.Tests/Application/Search/GetSearchFieldValuesHandlerTests.cs
T
timothy fc3ede09bc
PR Gates / Script tests (pytest) (pull_request) Successful in 51s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m38s
Review verdict / Set review-verdict status (pull_request) Successful in 1m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 25m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fc3ede0 (base: main)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 7s
PR Gates / Docs update reminder (pull_request) Successful in 8s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
docs(578): the retracted claim survived in 9 places, including the record title and rule
Comment- and docs-only; verified no non-comment line changed in any .cs.

I reported last round that I had "classified every surviving hit". That was false, and the false
confidence is the expensive part: a confidently-stated "I checked everything" stops anyone else
checking. The retracted wording survived in nine places, two of them the record's title and rule: —
and the catalog copies rule: verbatim, so the generated entry point and the record disagreed
semantically while docs/decisions.md said the correct thing.

Root cause of the miss, because it will recur otherwise: I built the sweep term list from the
DELETED MECHANISM's vocabulary (LIKE, superset, keyspace, anchor, over-match) and never added the
RETRACTED CLAIM's own words. "no predicate", "bound on work", "index entries", "no gap" and
"holds in memory" were never grepped. After a retraction the subject list has to include the words
of the thing being retracted, not just the thing already deleted.

Second, worse: my first attempt at this round's sweep printed nothing for every term and I nearly
read that as "all clear". zsh does not word-split an unquoted $FILES, so grep received one giant
non-existent path — and the `|| echo "(none)"` never fired because the pipeline's exit status was
sed's. Same failure shape as the bug arc itself: a check reporting success while examining nothing.
Re-run with a proper array plus a control term ("SongMetadata" -> 42 hits) so an empty result is
distinguishable from a broken grep.

Fixed all nine, replacing "no predicate" with the seekable-cursor-vs-residual distinction already
written correctly elsewhere:
- handler: the "real bound on work" claim, the short-page rationale
- SearchFieldValuesQueryShapeTests: "ANY predicate" + "reads exactly n index entries", and added what
  the test can and cannot pin (a SQL string, not a plan / visibility work / payload I/O)
- GetSearchFieldValuesHandlerTests: "no gap between what the engine looks at and what it hands back",
  and the current-behaviour comment
- record title, rule:, attempt-5 table row; api-conventions
- regenerated docs/decisions/README.md so catalog and record agree again

Tenth item, the same overclaim one level down and it survived the first retraction: the row bound was
said to cap what the process holds in memory. It does not — payload width is unrestricted and one
JSON array can contain arbitrarily many strings, each of which may enter the in-memory distinct set.
It caps logical rows returned/materialized and round-trip count, nothing about bytes. Added as a
third struck-through bullet next to the other two retractions.
2026-07-27 03:10:36 +02:00

657 lines
28 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" }));
}
}