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
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.
657 lines
28 KiB
C#
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" }));
|
|
}
|
|
}
|