Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
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()
|
||
{
|
||
// Bounding the Id KEYSPACE is the killer here: 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" }));
|
||
}
|
||
}
|