using System.Globalization; using System.Resources; using System.Text.RegularExpressions; using AvParser.Core.Settings; using AvParser.UI.Localization; namespace AvParser.UI.Tests; public sealed class LocalizationTests : IDisposable { private static readonly CultureInfo English = CultureInfo.GetCultureInfo("en"); private static readonly CultureInfo Russian = CultureInfo.GetCultureInfo("ru"); private static readonly ResourceManager Resources = new( "AvParser.UI.Localization.Strings", typeof(Localizer).Assembly ); /// Leaves the localizer where the rest of the suite expects it. public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English); /// /// Reads one language's resources without letting it fall back to another. /// /// /// English lives in the neutral Strings.resx, which resolves under the invariant /// culture rather than under "en". tryParents: false matters: with fallback on, a /// missing Russian satellite would quietly serve English and every comparison below would /// pass while the app shipped untranslated. /// private static IReadOnlyDictionary Read(CultureInfo culture) { var lookup = Equals(culture, English) ? CultureInfo.InvariantCulture : culture; // Deliberately not disposed: ResourceManager hands back a cached instance, and disposing // it breaks every later read in the assembly. var set = Resources.GetResourceSet(lookup, createIfNotExists: true, tryParents: false); set.ShouldNotBeNull(); return set.Cast() .ToDictionary( entry => (string)entry.Key, entry => (string?)entry.Value ?? string.Empty, StringComparer.Ordinal ); } [Fact] public void Russian_translates_every_english_key() { var english = Read(English); var russian = Read(Russian); english.ShouldNotBeEmpty(); russian.Keys.Except(english.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian has keys English does not"); english.Keys.Except(russian.Keys, StringComparer.Ordinal).ShouldBeEmpty("Russian is missing keys"); } [Fact] public void No_translation_is_blank() => Read(Russian).Where(entry => string.IsNullOrWhiteSpace(entry.Value)).Select(entry => entry.Key).ShouldBeEmpty(); [Fact] public void Format_placeholders_match_between_languages() { var english = Read(English); var russian = Read(Russian); // A translation that drops a {0} throws at runtime rather than merely reading oddly, so // the placeholder sets have to be identical. var mismatched = english .Where(entry => Placeholders(entry.Value) != Placeholders(russian[entry.Key])) .Select(entry => entry.Key) .ToArray(); mismatched.ShouldBeEmpty(); } [Fact] public void Every_plural_key_has_all_three_forms() { var english = Read(English); // Russian needs One/Few/Many; a family missing one of them would silently render "!key!". var families = english .Keys.Where(key => key.EndsWith(".One", StringComparison.Ordinal)) .Select(key => key[..^4]) .ToArray(); families.ShouldNotBeEmpty(); foreach (var family in families) { foreach (var suffix in new[] { "One", "Few", "Many" }) { english.ShouldContainKey($"{family}.{suffix}"); } } } [Theory] [InlineData(1, "One")] [InlineData(2, "Few")] [InlineData(4, "Few")] [InlineData(5, "Many")] [InlineData(11, "Many")] [InlineData(12, "Many")] [InlineData(14, "Many")] [InlineData(21, "One")] [InlineData(22, "Few")] [InlineData(25, "Many")] [InlineData(101, "One")] [InlineData(111, "Many")] [InlineData(0, "Many")] public void Russian_plural_rules(int count, string expected) => Localizer.SuffixFor(count, Russian).ShouldBe(expected); [Theory] [InlineData(0, "Many")] [InlineData(1, "One")] [InlineData(2, "Many")] [InlineData(21, "Many")] public void English_plural_rules(int count, string expected) => Localizer.SuffixFor(count, English).ShouldBe(expected); [Fact] public void Plural_lookup_picks_the_right_form() { Localizer.Instance.SetLanguage(AppLanguage.Russian); Localizer.Instance.Plural("Collect.Count.Images", 1).ShouldBe("1 изображение"); Localizer.Instance.Plural("Collect.Count.Images", 3).ShouldBe("3 изображения"); Localizer.Instance.Plural("Collect.Count.Images", 7).ShouldBe("7 изображений"); } [Fact] public void Switching_language_changes_what_lookups_return() { Localizer.Instance.SetLanguage(AppLanguage.English); var english = Localizer.Instance["Page.Settings"]; Localizer.Instance.SetLanguage(AppLanguage.Russian); Localizer.Instance["Page.Settings"].ShouldNotBe(english); Localizer.Instance["Page.Settings"].ShouldBe("Настройки"); } [Fact] public void Switching_language_invalidates_bindings() { Localizer.Instance.SetLanguage(AppLanguage.English); var notifications = new List(); void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => notifications.Add(e.PropertyName); Localizer.Instance.PropertyChanged += Handler; try { Localizer.Instance.SetLanguage(AppLanguage.Russian); } finally { Localizer.Instance.PropertyChanged -= Handler; } // "Item[]" is what makes every {l:Loc} binding re-read; without it the UI would keep the // previous language until it was rebuilt. notifications.ShouldContain("Item[]"); } [Fact] public void Setting_the_same_language_twice_does_not_churn_bindings() { Localizer.Instance.SetLanguage(AppLanguage.Russian); var raised = 0; void Handler(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => raised++; Localizer.Instance.PropertyChanged += Handler; try { Localizer.Instance.SetLanguage(AppLanguage.Russian); } finally { Localizer.Instance.PropertyChanged -= Handler; } raised.ShouldBe(0); } [Fact] public void An_unknown_key_is_visibly_marked() => Localizer.Instance["No.Such.Key"].ShouldBe("!No.Such.Key!"); [Fact] public void A_fallback_is_used_when_a_key_is_absent() => Localizer.Instance.GetOrDefault("Source.brand-new.Name", "Brand new").ShouldBe("Brand new"); [Theory] [InlineData(AppLanguage.English, "en")] [InlineData(AppLanguage.Russian, "ru")] public void Explicit_languages_map_to_their_culture(AppLanguage language, string expected) => AppLanguages.ToCulture(language).TwoLetterISOLanguageName.ShouldBe(expected); [Theory] [InlineData("ru-RU", "ru")] [InlineData("ru", "ru")] [InlineData("en-GB", "en")] [InlineData("de-DE", "en")] [InlineData("ja-JP", "en")] public void System_follows_the_os_only_where_a_translation_exists(string system, string expected) => // A half-translated German would be worse than plain English, so anything unsupported // lands on English rather than on the OS language. AppLanguages .ToCulture(AppLanguage.System, CultureInfo.GetCultureInfo(system)) .TwoLetterISOLanguageName.ShouldBe(expected); private static string Placeholders(string value) => string.Concat(Regex.Matches(value, @"\{\d+\}").Select(match => match.Value).Order()); }