mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-10-30 18:45:26 +00:00
537 lines
21 KiB
C#
537 lines
21 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using BitFaster.Caching;
|
|
using Emby.Server.Implementations.Localization;
|
|
using MediaBrowser.Controller.Configuration;
|
|
using MediaBrowser.Model.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Jellyfin.Server.Implementations.Tests.Localization
|
|
{
|
|
public class LocalizationManagerTests
|
|
{
|
|
[Fact]
|
|
public void GetCountries_All_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "de-DE"
|
|
});
|
|
var countries = localizationManager.GetCountries().ToList();
|
|
|
|
Assert.Equal(140, countries.Count);
|
|
|
|
var germany = countries.FirstOrDefault(x => x.Name.Equals("DE", StringComparison.Ordinal));
|
|
Assert.NotNull(germany);
|
|
Assert.Equal("Germany", germany!.DisplayName);
|
|
Assert.Equal("DEU", germany.ThreeLetterISORegionName);
|
|
Assert.Equal("DE", germany.TwoLetterISORegionName);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetCultures_All_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "de-DE"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
var cultures = localizationManager.GetCultures().ToList();
|
|
|
|
Assert.Equal(496, cultures.Count);
|
|
|
|
var germany = cultures.FirstOrDefault(x => x.TwoLetterISOLanguageName.Equals("de", StringComparison.Ordinal));
|
|
Assert.NotNull(germany);
|
|
Assert.Equal("deu", germany!.ThreeLetterISOLanguageName);
|
|
Assert.Equal("German", germany.DisplayName);
|
|
Assert.Equal("German", germany.Name);
|
|
Assert.Contains("deu", germany.ThreeLetterISOLanguageNames);
|
|
Assert.Contains("ger", germany.ThreeLetterISOLanguageNames);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGetISO6392TFromB_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "de-DE"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
string? isoT;
|
|
|
|
// Translation ger -> deu
|
|
Assert.True(localizationManager.TryGetISO6392TFromB("ger", out isoT));
|
|
Assert.Equal("deu", isoT);
|
|
|
|
// chi -> zho
|
|
Assert.True(localizationManager.TryGetISO6392TFromB("chi", out isoT));
|
|
Assert.Equal("zho", isoT);
|
|
|
|
// eng is already ISO 639-2/T
|
|
Assert.False(localizationManager.TryGetISO6392TFromB("eng", out isoT));
|
|
Assert.Null(isoT);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("de")]
|
|
[InlineData("deu")]
|
|
[InlineData("ger")]
|
|
[InlineData("german")]
|
|
public async Task FindLanguageInfo_Valid_Success(string identifier)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "de-DE"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var germany = localizationManager.FindLanguageInfo(identifier);
|
|
Assert.NotNull(germany);
|
|
|
|
Assert.Equal("deu", germany!.ThreeLetterISOLanguageName);
|
|
Assert.Equal("German", germany.DisplayName);
|
|
Assert.Equal("German", germany.Name);
|
|
Assert.Contains("deu", germany.ThreeLetterISOLanguageNames);
|
|
Assert.Contains("ger", germany.ThreeLetterISOLanguageNames);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("mul", "Multiple languages")]
|
|
[InlineData("und", "Undetermined")]
|
|
[InlineData("mis", "Uncoded languages")]
|
|
[InlineData("zxx", "No linguistic content; Not applicable")]
|
|
public async Task FindLanguageInfo_ISO6392Only_Success(string code, string expectedDisplayName)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var culture = localizationManager.FindLanguageInfo(code);
|
|
Assert.NotNull(culture);
|
|
Assert.Equal(expectedDisplayName, culture.DisplayName);
|
|
Assert.Equal(code, culture.ThreeLetterISOLanguageName);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ell", "Greek")] // Comma truncation
|
|
[InlineData("nld", "Dutch")] // Semicolon truncation
|
|
[InlineData("ron", "Romanian")] // Semicolon truncation, multiple
|
|
[InlineData("eng", "English")] // No truncation
|
|
[InlineData("zh-CN", "Chinese (Simplified)")] // No truncation, with parentheses
|
|
public async Task GetLanguageDisplayName_DelimitedName_ReturnsTruncatedName(string language, string expected)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var result = localizationManager.GetLanguageDisplayName(language);
|
|
Assert.Equal(expected, result);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData("xyz")]
|
|
public async Task GetLanguageDisplayName_InvalidInput_ReturnsNull(string? language)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var result = localizationManager.GetLanguageDisplayName(language!);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParentalRatings_Default_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "de-DE"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
var ratings = localizationManager.GetParentalRatings().ToList();
|
|
|
|
Assert.Equal(56, ratings.Count);
|
|
|
|
var tvma = ratings.FirstOrDefault(x => x.Name.Equals("TV-MA", StringComparison.Ordinal));
|
|
Assert.NotNull(tvma);
|
|
Assert.Equal(17, tvma!.RatingScore!.Score);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetParentalRatings_ConfiguredCountryCode_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
MetadataCountryCode = "DE"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
var ratings = localizationManager.GetParentalRatings().ToList();
|
|
|
|
Assert.Equal(24, ratings.Count);
|
|
|
|
var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal));
|
|
Assert.NotNull(fsk);
|
|
Assert.Equal(12, fsk!.RatingScore!.Score);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("CA-R", "CA", 18, 1)]
|
|
[InlineData("FSK-16", "DE", 16, null)]
|
|
[InlineData("FSK-18", "DE", 18, null)]
|
|
[InlineData("FSK-18", "US", 18, null)]
|
|
[InlineData("TV-MA", "US", 17, 1)]
|
|
[InlineData("XXX", "asdf", 1000, null)]
|
|
[InlineData("Germany: FSK-18", "DE", 18, null)]
|
|
[InlineData("Rated : R", "US", 17, 0)]
|
|
[InlineData("Rated: R", "US", 17, 0)]
|
|
[InlineData("Rated R", "US", 17, 0)]
|
|
[InlineData(" PG-13 ", "US", 13, 0)]
|
|
[InlineData("T", "IT", 0, null)]
|
|
[InlineData("VM6", "IT", 6, null)]
|
|
[InlineData("VM12", "IT", 12, null)]
|
|
[InlineData("VM14", "IT", 14, null)]
|
|
[InlineData("VM18", "IT", 18, null)]
|
|
[InlineData("IT-VM14", "IT", 14, null)] // TMDB style country prefix
|
|
[InlineData("IT-VM18", "IT", 18, null)]
|
|
[InlineData("it-vm18", "IT", 18, null)] // Rating strings are case insensitive
|
|
[InlineData("VM 18", "IT", 18, null)]
|
|
[InlineData("Vietato ai minori di 18 anni", "IT", 18, null)]
|
|
public async Task GetRatingLevel_GivenValidString_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
MetadataCountryCode = countryCode
|
|
});
|
|
await localizationManager.LoadAll();
|
|
var score = localizationManager.GetRatingScore(value);
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Theory]
|
|
// Rating strings are stored mixed-case in the *.json rating systems and must match regardless of casing
|
|
[InlineData("btl", "se", 0, null)] // Direct lookup, lowercase of "Btl"
|
|
[InlineData("BARNTILLÅTEN", "se", 0, null)] // Direct lookup, uppercase incl. diacritics
|
|
[InlineData("SE-BTL", "se", 0, null)] // Country prefix stripped against the configured country
|
|
[InlineData("SE-BTL", "us", 0, null)] // Country prefix resolved via the separator fallback
|
|
[InlineData("Från 7 År", "se", 7, null)] // Diacritic casing (json has "Från 7 år")
|
|
[InlineData("SE-Från 7 År", "us", 7, null)] // Same, via the separator fallback
|
|
[InlineData("fsk-16", "de", 16, null)] // Not Sweden specific: lowercase of "FSK-16"
|
|
public async Task GetRatingScore_IsCaseInsensitive_Success(string value, string countryCode, int? expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
MetadataCountryCode = countryCode
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var score = localizationManager.GetRatingScore(value);
|
|
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("0", 0, null)]
|
|
[InlineData("1", 1, null)]
|
|
[InlineData("6", 6, null)]
|
|
[InlineData("12", 12, null)]
|
|
[InlineData("42", 42, null)]
|
|
[InlineData("9999", 9999, null)]
|
|
public async Task GetRatingLevel_GivenValidAge_Success(string value, int? expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "nl" });
|
|
await localizationManager.LoadAll();
|
|
var score = localizationManager.GetRatingScore(value);
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRatingLevel_GivenUnratedString_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
UICulture = "de-DE"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
Assert.Null(localizationManager.GetRatingScore("NR"));
|
|
Assert.Null(localizationManager.GetRatingScore("unrated"));
|
|
Assert.Null(localizationManager.GetRatingScore("Not Rated"));
|
|
Assert.Null(localizationManager.GetRatingScore("n/a"));
|
|
Assert.Null(localizationManager.GetRatingScore("N/A"));
|
|
Assert.Null(localizationManager.GetRatingScore(" n/a "));
|
|
}
|
|
|
|
[Theory]
|
|
// "NR" and "UR" are rating strings of some systems, so they must stay unrated when listed alongside others
|
|
[InlineData("NR / R", 17, 0)]
|
|
[InlineData("unrated / R", 17, 0)]
|
|
[InlineData("R / NR", 17, 0)]
|
|
public async Task GetRatingLevel_SkipsUnratedListEntries_Success(string value, int? expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration { MetadataCountryCode = "us" });
|
|
await localizationManager.LoadAll();
|
|
|
|
var score = localizationManager.GetRatingScore(value);
|
|
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Theory]
|
|
// Ratings that contain a '/' themselves must not be split into a list of ratings
|
|
[InlineData("M/3", "pt", 3, null)]
|
|
[InlineData("M/12", "pt", 12, null)]
|
|
[InlineData("M/18", "pt", 18, null)]
|
|
[InlineData("PT-M/12", "pt", 12, null)] // TMDB style country prefix
|
|
[InlineData("M/12", "us", 12, null)] // Resolved through the all-systems fallback
|
|
[InlineData("U/A 13+", "in", 13, null)]
|
|
[InlineData("7/i", "es", 11, null)]
|
|
[InlineData("7/i/fig", "es", 11, null)]
|
|
[InlineData("18/fig", "es", 18, null)]
|
|
public async Task GetRatingScore_RatingContainingSlash_IsNotSplit(string value, string countryCode, int expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
MetadataCountryCode = countryCode
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var score = localizationManager.GetRatingScore(value);
|
|
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("-NO RATING SHOWN-")]
|
|
[InlineData(":NO RATING SHOWN:")]
|
|
public async Task GetRatingLevel_Split_Success(string value)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
Assert.Null(localizationManager.GetRatingScore(value));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("TV-MA", "DE", 17, 1)] // US-only rating, DE country code
|
|
[InlineData("PG-13", "FR", 13, 0)] // US-only rating, FR country code
|
|
[InlineData("R", "JP", 17, 0)] // US-only rating, JP country code
|
|
public async Task GetRatingScore_FallbackPrioritizesUS_Success(string rating, string countryCode, int expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
MetadataCountryCode = countryCode
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var score = localizationManager.GetRatingScore(rating);
|
|
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("US:INVALID", "US")] // Colon separator, known country code, unknown rating
|
|
[InlineData("us:INVALID", "US")] // Colon separator, lowercase country code
|
|
[InlineData("DE-INVALID", "US")] // Hyphen separator, known language prefix, unknown rating
|
|
[InlineData("ca:INVALID", "US")] // Colon separator, known country code (Canada)
|
|
public async Task GetRatingScore_UnknownRatingWithKnownCountry_ReturnsNull(string rating, string countryCode)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
MetadataCountryCode = countryCode
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
Assert.Null(localizationManager.GetRatingScore(rating));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("us:R", "DE", 17, 0)] // Colon separator, explicit US country, valid US rating
|
|
[InlineData("US:PG-13", "DE", 13, 0)] // Colon separator, explicit US country, valid US rating
|
|
[InlineData("ca:R", "US", 18, 1)] // Colon separator, Canada country code, valid CA rating
|
|
public async Task GetRatingScore_ValidRatingWithCountrySeparator_ReturnsScore(string rating, string countryCode, int expectedScore, int? expectedSubScore)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
MetadataCountryCode = countryCode
|
|
});
|
|
await localizationManager.LoadAll();
|
|
|
|
var score = localizationManager.GetRatingScore(rating);
|
|
Assert.NotNull(score);
|
|
Assert.Equal(expectedScore, score.Score);
|
|
Assert.Equal(expectedSubScore, score.SubScore);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Default", "Default")]
|
|
[InlineData("HeaderLiveTV", "Live TV")]
|
|
public void GetLocalizedString_Valid_Success(string key, string expected)
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
var translated = localizationManager.GetLocalizedString(key);
|
|
Assert.NotNull(translated);
|
|
Assert.Equal(expected, translated);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLocalizedString_Invalid_Success()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration()
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
var key = "SuperInvalidTranslationKeyThatWillNeverBeAdded";
|
|
|
|
var translated = localizationManager.GetLocalizedString(key);
|
|
Assert.NotNull(translated);
|
|
Assert.Equal(key, translated);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLocalizedString_WithCulture_ReturnsTranslation()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
var translated = localizationManager.GetLocalizedString("Artists", "de");
|
|
Assert.Equal("Künstler", translated);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLocalizedString_WithCulture_FallsBackToEnUs()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
// A culture with no translation file should fall back to en-US
|
|
var translated = localizationManager.GetLocalizedString("Artists", "zz");
|
|
Assert.Equal("Artists", translated);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLocalizedString_WithBcp47Normalization_ReturnsTranslation()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
// es-419 is stored as es_419 in Jellyfin
|
|
var translated = localizationManager.GetLocalizedString("Default", "es-419");
|
|
Assert.NotEqual("Default", translated);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLocalizedString_WithBcp47NormalizationToUppercaseRegion_ReturnsTranslation()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
// he-IL normalizes to the underscore resource he_IL. The resource lookup is case-sensitive,
|
|
// so the region casing has to be preserved or the file is not found and we fall back to en-US.
|
|
var translated = localizationManager.GetLocalizedString("Books", "he-IL");
|
|
Assert.Equal("ספרים", translated);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetServerLocalizedString_UsesServerCulture()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "de"
|
|
});
|
|
|
|
// Even if CurrentUICulture is fr, GetServerLocalizedString should use the server's "de"
|
|
var previousCulture = CultureInfo.CurrentUICulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr");
|
|
var translated = localizationManager.GetServerLocalizedString("Artists");
|
|
Assert.Equal("Künstler", translated);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentUICulture = previousCulture;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLocalizedString_UsesCurrentUICulture()
|
|
{
|
|
var localizationManager = Setup(new ServerConfiguration
|
|
{
|
|
UICulture = "en-US"
|
|
});
|
|
|
|
var previousCulture = CultureInfo.CurrentUICulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("de");
|
|
var translated = localizationManager.GetLocalizedString("Artists");
|
|
Assert.Equal("Künstler", translated);
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentUICulture = previousCulture;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void GetSupportedUICultures_IncludesCommonCultures()
|
|
{
|
|
var supported = LocalizationManager.GetSupportedUICultures();
|
|
Assert.Contains(supported, c => c.Name.Equals("de", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(supported, c => c.Name.Equals("en-US", StringComparison.OrdinalIgnoreCase));
|
|
Assert.Contains(supported, c => c.Name.Equals("fr", StringComparison.OrdinalIgnoreCase));
|
|
// Underscore variants get normalized to BCP-47 hyphen form for CultureInfo compatibility.
|
|
Assert.Contains(supported, c => c.Name.Equals("es-419", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
private LocalizationManager Setup(ServerConfiguration config)
|
|
{
|
|
var mockConfiguration = new Mock<IServerConfigurationManager>();
|
|
mockConfiguration.SetupGet(x => x.Configuration).Returns(config);
|
|
|
|
return new LocalizationManager(mockConfiguration.Object, new NullLogger<LocalizationManager>());
|
|
}
|
|
}
|
|
}
|