Add focused unit tests
This commit is contained in:
@@ -7,6 +7,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D1AD3B53-612
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Multilang", "src\Jellyfin.Plugin.Multilang\Jellyfin.Plugin.Multilang.csproj", "{138DB6FA-E217-41B3-A9D1-F24FB724DD44}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CFC94729-5950-416B-AAA2-640C80D578C7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Multilang.Tests", "tests\Jellyfin.Plugin.Multilang.Tests\Jellyfin.Plugin.Multilang.Tests.csproj", "{C58F148E-F158-4D78-B3C1-2B3317B24A12}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -20,8 +24,13 @@ Global
|
||||
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{138DB6FA-E217-41B3-A9D1-F24FB724DD44}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C58F148E-F158-4D78-B3C1-2B3317B24A12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C58F148E-F158-4D78-B3C1-2B3317B24A12}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C58F148E-F158-4D78-B3C1-2B3317B24A12}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C58F148E-F158-4D78-B3C1-2B3317B24A12}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{138DB6FA-E217-41B3-A9D1-F24FB724DD44} = {D1AD3B53-6123-4F98-87D5-517B5BF6ADAF}
|
||||
{C58F148E-F158-4D78-B3C1-2B3317B24A12} = {CFC94729-5950-416B-AAA2-640C80D578C7}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using Jellyfin.Plugin.Multilang.Rules;
|
||||
|
||||
namespace Jellyfin.Plugin.Multilang.Tests;
|
||||
|
||||
public sealed class CategoryRuleEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsWithAndListRequiresExactSet()
|
||||
{
|
||||
var category = Rule("production_countries", "is", useOr: false, "SE", "FI");
|
||||
|
||||
Assert.True(CategoryRuleEvaluator.Matches(category, Facts(productionCountries: ["FI", "SE"])));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(category, Facts(productionCountries: ["FI"])));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(category, Facts(productionCountries: ["FI", "SE", "NO"])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsWithOrListRequiresSingleMatchingValue()
|
||||
{
|
||||
var category = Rule("original_language", "is", useOr: true, "sv", "fi");
|
||||
|
||||
Assert.True(CategoryRuleEvaluator.Matches(category, Facts(originalLanguage: "sv")));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(category, Facts(originalLanguage: "en")));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(category, Facts(originalLanguage: "", spokenLanguages: ["sv", "fi"])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsWithAndListRequiresEveryExpectedValue()
|
||||
{
|
||||
var category = Rule("spoken_languages", "contains", useOr: false, "sv", "fi");
|
||||
|
||||
Assert.True(CategoryRuleEvaluator.Matches(category, Facts(spokenLanguages: ["en", "sv", "fi"])));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(category, Facts(spokenLanguages: ["en", "sv"])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContainsWithOrListRequiresAnyExpectedValue()
|
||||
{
|
||||
var category = Rule("origin_countries", "contains", useOr: true, "SE", "FI");
|
||||
|
||||
Assert.True(CategoryRuleEvaluator.Matches(category, Facts(originCountries: ["NO", "FI"])));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(category, Facts(originCountries: ["NO", "DK"])));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleConditionsRespectCategoryMatchMode()
|
||||
{
|
||||
var all = new UserCategoryRule
|
||||
{
|
||||
Scopes = ["M"],
|
||||
MatchAllConditions = true,
|
||||
Requirements =
|
||||
[
|
||||
Requirement("original_language", "is", false, "sv"),
|
||||
Requirement("production_countries", "contains", true, "SE")
|
||||
]
|
||||
};
|
||||
var any = CopyWithMatchAll(all, false);
|
||||
var swedishLanguageOnly = Facts(originalLanguage: "sv", productionCountries: ["FI"]);
|
||||
|
||||
Assert.False(CategoryRuleEvaluator.Matches(all, swedishLanguageOnly));
|
||||
Assert.True(CategoryRuleEvaluator.Matches(any, swedishLanguageOnly));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeMustMatchItemKind()
|
||||
{
|
||||
var movieOnly = Rule("original_language", "is", useOr: false, "sv");
|
||||
movieOnly.Scopes = ["M"];
|
||||
|
||||
Assert.True(CategoryRuleEvaluator.Matches(movieOnly, Facts(kind: "movie", originalLanguage: "sv")));
|
||||
Assert.False(CategoryRuleEvaluator.Matches(movieOnly, Facts(kind: "tv", originalLanguage: "sv")));
|
||||
}
|
||||
|
||||
private static UserCategoryRule Rule(string field, string relation, bool useOr, params string[] values)
|
||||
=> new()
|
||||
{
|
||||
Scopes = ["M", "S", "C"],
|
||||
Requirements = [Requirement(field, relation, useOr, values)]
|
||||
};
|
||||
|
||||
private static UserCategoryRequirement Requirement(string field, string relation, bool useOr, params string[] values)
|
||||
=> new()
|
||||
{
|
||||
Fields = [field],
|
||||
Relation = relation,
|
||||
UseOr = useOr,
|
||||
Values = values
|
||||
};
|
||||
|
||||
private static UserCategoryRule CopyWithMatchAll(UserCategoryRule rule, bool matchAll)
|
||||
=> new()
|
||||
{
|
||||
Id = rule.Id,
|
||||
Label = rule.Label,
|
||||
CriteriaText = rule.CriteriaText,
|
||||
Requirements = rule.Requirements,
|
||||
MatchAllConditions = matchAll,
|
||||
Scopes = rule.Scopes,
|
||||
FieldActions = rule.FieldActions,
|
||||
FieldActionLists = rule.FieldActionLists
|
||||
};
|
||||
|
||||
private static FactsData Facts(
|
||||
string kind = "movie",
|
||||
string originalLanguage = "",
|
||||
string[]? originCountries = null,
|
||||
string[]? productionCountries = null,
|
||||
string[]? spokenLanguages = null,
|
||||
string audioLanguage = "")
|
||||
=> new(
|
||||
"item",
|
||||
"tmdb",
|
||||
kind,
|
||||
"Original title",
|
||||
originalLanguage,
|
||||
originalLanguage,
|
||||
JsonSerializer.Serialize(originCountries ?? []),
|
||||
JsonSerializer.Serialize(productionCountries ?? []),
|
||||
JsonSerializer.Serialize(spokenLanguages ?? []),
|
||||
audioLanguage,
|
||||
"[]",
|
||||
0,
|
||||
0);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Jellyfin.Plugin.Multilang.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Jellyfin.Plugin.Multilang.Tests;
|
||||
|
||||
public sealed class ItemsProxyRequestBuilderTests
|
||||
{
|
||||
private const string UserId = "11111111111111111111111111111111";
|
||||
private const string OtherUserId = "22222222222222222222222222222222";
|
||||
|
||||
[Fact]
|
||||
public void BuildNormalizesRootItemsPathToCurrentUser()
|
||||
{
|
||||
var request = HttpRequest();
|
||||
|
||||
var result = ItemsProxyRequestBuilder.Build(
|
||||
request,
|
||||
"/Items?Recursive=true&api_key=old",
|
||||
"token",
|
||||
UserId,
|
||||
multilangEnabled: true);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath);
|
||||
Assert.Contains("api_key=token", result.Upstream.Query);
|
||||
Assert.DoesNotContain("api_key=old", result.Upstream.Query);
|
||||
Assert.DoesNotContain("api_key", result.NormalizedUrlForCache, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("userId=" + UserId, result.NormalizedUrlForCache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildReplacesForeignUserIdInUpstreamPath()
|
||||
{
|
||||
var request = HttpRequest();
|
||||
|
||||
var result = ItemsProxyRequestBuilder.Build(
|
||||
request,
|
||||
$"/Users/{OtherUserId}/Items?Limit=5",
|
||||
"token",
|
||||
UserId,
|
||||
multilangEnabled: true);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal($"/Users/{UserId}/Items", result.Upstream.AbsolutePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildKeepsLocalListControlsOutOfUpstreamWhenEnabled()
|
||||
{
|
||||
var request = HttpRequest("?mlLocale=fi-FI");
|
||||
|
||||
var result = ItemsProxyRequestBuilder.Build(
|
||||
request,
|
||||
"/Items?SortBy=SortName&SortOrder=Descending&StartIndex=20&Limit=10&NameStartsWith=L",
|
||||
"token",
|
||||
UserId,
|
||||
multilangEnabled: true);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("SortName", result.Controls.SortBy);
|
||||
Assert.Equal("Descending", result.Controls.SortOrder);
|
||||
Assert.Equal(20, result.Controls.StartIndex);
|
||||
Assert.Equal(10, result.Controls.Limit);
|
||||
Assert.Equal("L", result.Controls.NameStartsWith);
|
||||
Assert.Equal("fi-FI", result.Controls.ClientLocale);
|
||||
Assert.DoesNotContain("SortBy=", result.Upstream.Query);
|
||||
Assert.DoesNotContain("Limit=", result.Upstream.Query);
|
||||
Assert.Contains("|SortName|Descending|L|20|10|fi-FI|", result.CacheKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLeavesSortAndPagingUpstreamWhenMultilangDisabled()
|
||||
{
|
||||
var request = HttpRequest();
|
||||
|
||||
var result = ItemsProxyRequestBuilder.Build(
|
||||
request,
|
||||
"/Items?SortBy=SortName&Limit=10",
|
||||
"token",
|
||||
UserId,
|
||||
multilangEnabled: false);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(string.Empty, result.Controls.SortBy);
|
||||
Assert.Equal(0, result.Controls.Limit);
|
||||
Assert.Contains("SortBy=SortName", result.Upstream.Query);
|
||||
Assert.Contains("Limit=10", result.Upstream.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRejectsAbsoluteUrlForAnotherHost()
|
||||
{
|
||||
var request = HttpRequest();
|
||||
|
||||
var result = ItemsProxyRequestBuilder.Build(
|
||||
request,
|
||||
"http://other-host:8096/Items",
|
||||
"token",
|
||||
UserId,
|
||||
multilangEnabled: true);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLocalGenreIdsOnlyReturnsTmdbIds()
|
||||
{
|
||||
Assert.Equal([1, 2], ItemsProxyRequestBuilder.ParseLocalGenreIds("tmdb-1,abc,2,tmdb-2,tmdb-x,1"));
|
||||
}
|
||||
|
||||
private static HttpRequest HttpRequest(string query = "")
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Scheme = "http";
|
||||
context.Request.Host = new HostString("server.local", 8096);
|
||||
context.Request.QueryString = new QueryString(query);
|
||||
return context.Request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Jellyfin.Plugin.Multilang.Data;
|
||||
using Jellyfin.Plugin.Multilang.Services;
|
||||
|
||||
namespace Jellyfin.Plugin.Multilang.Tests;
|
||||
|
||||
public sealed class ItemsProxyTransformerResolutionTests
|
||||
{
|
||||
private const string TitleField = "title";
|
||||
private const string OverviewField = "overview";
|
||||
private const string TaglineField = "tagline";
|
||||
private const string PosterKind = "poster";
|
||||
private const string LogoKind = "logo";
|
||||
private const string OriginalAction = "Original";
|
||||
private const string LanguagePrefix = "Language:";
|
||||
|
||||
[Fact]
|
||||
public void ResolveFieldUsesFirstLanguageWithData()
|
||||
{
|
||||
var translations = TextTranslations(
|
||||
("sv", OverviewField, ""),
|
||||
("fi", OverviewField, "Finnish overview"),
|
||||
("en", OverviewField, "English overview"));
|
||||
|
||||
var result = ItemsProxyTransformer.ResolveField(
|
||||
OverviewField,
|
||||
[LanguagePrefix + "sv", LanguagePrefix + "fi", LanguagePrefix + "en"],
|
||||
Facts(),
|
||||
translations);
|
||||
|
||||
Assert.True(result.Change);
|
||||
Assert.Equal("Finnish overview", result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFieldUsesOriginalTitleBeforeTranslations()
|
||||
{
|
||||
var result = ItemsProxyTransformer.ResolveField(
|
||||
TitleField,
|
||||
[OriginalAction, LanguagePrefix + "en"],
|
||||
Facts(originalTitle: "Original title"),
|
||||
TextTranslations(("en", TitleField, "English title")));
|
||||
|
||||
Assert.True(result.Change);
|
||||
Assert.Equal("Original title", result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFieldFallsBackToJellyfinForMissingTitle()
|
||||
{
|
||||
var result = ItemsProxyTransformer.ResolveField(
|
||||
TitleField,
|
||||
[LanguagePrefix + "sv"],
|
||||
Facts(),
|
||||
TextTranslations());
|
||||
|
||||
Assert.False(result.Change);
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFieldClearsMissingOptionalText()
|
||||
{
|
||||
var result = ItemsProxyTransformer.ResolveField(
|
||||
TaglineField,
|
||||
[LanguagePrefix + "sv"],
|
||||
Facts(),
|
||||
TextTranslations());
|
||||
|
||||
Assert.True(result.Change);
|
||||
Assert.Equal(string.Empty, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAssetUsesFirstLanguageWithData()
|
||||
{
|
||||
var assets = AssetTranslations(
|
||||
(PosterKind, "sv", ""),
|
||||
(PosterKind, "fi", "https://image/fi.jpg"),
|
||||
(PosterKind, "en", "https://image/en.jpg"));
|
||||
|
||||
var result = ItemsProxyTransformer.ResolveAsset(
|
||||
PosterKind,
|
||||
[LanguagePrefix + "sv", LanguagePrefix + "fi", LanguagePrefix + "en"],
|
||||
assets);
|
||||
|
||||
Assert.True(result.Change);
|
||||
Assert.Equal("https://image/fi.jpg", result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAssetFallsBackToJellyfinForMissingPoster()
|
||||
{
|
||||
var result = ItemsProxyTransformer.ResolveAsset(
|
||||
PosterKind,
|
||||
[LanguagePrefix + "sv"],
|
||||
AssetTranslations());
|
||||
|
||||
Assert.False(result.Change);
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAssetClearsMissingOptionalImage()
|
||||
{
|
||||
var result = ItemsProxyTransformer.ResolveAsset(
|
||||
LogoKind,
|
||||
[LanguagePrefix + "sv"],
|
||||
AssetTranslations());
|
||||
|
||||
Assert.True(result.Change);
|
||||
Assert.Equal(string.Empty, result.Value);
|
||||
}
|
||||
|
||||
private static Dictionary<string, Dictionary<string, string>> TextTranslations(params (string Lang, string Field, string Value)[] rows)
|
||||
{
|
||||
var result = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (lang, field, value) in rows)
|
||||
{
|
||||
if (!result.TryGetValue(lang, out var byField))
|
||||
{
|
||||
byField = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
result[lang] = byField;
|
||||
}
|
||||
|
||||
byField[field] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Dictionary<string, Dictionary<string, string>> AssetTranslations(params (string Kind, string Lang, string Value)[] rows)
|
||||
{
|
||||
var result = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (kind, lang, value) in rows)
|
||||
{
|
||||
if (!result.TryGetValue(kind, out var byLang))
|
||||
{
|
||||
byLang = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
result[kind] = byLang;
|
||||
}
|
||||
|
||||
byLang[lang] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static FactsData Facts(string originalTitle = "")
|
||||
=> new(
|
||||
"item",
|
||||
"tmdb",
|
||||
"movie",
|
||||
originalTitle,
|
||||
"",
|
||||
"",
|
||||
"[]",
|
||||
"[]",
|
||||
"[]",
|
||||
"",
|
||||
"[]",
|
||||
0,
|
||||
0);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Plugin.Multilang\Jellyfin.Plugin.Multilang.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user