using System.Collections; using System.Globalization; using System.Reflection; using System.Resources; using System.Text.RegularExpressions; using MeterVault.App.Localization; namespace MeterVault.Integration.Tests.Localization; /// /// Guards the UI string catalogue (SDD §12, M7). /// /// /// Resource lookup fails soft by design — ask for a key the German satellite doesn't carry and /// quietly serves the English one. That is the right runtime /// behaviour and the wrong build behaviour: a half-translated release would look perfectly healthy. /// These tests read each culture's resource set with tryParents: false, which is the only way /// to see what a satellite actually contains, and turn "untranslated" back into a failure. /// No database, so they run without Docker. /// public sealed class StringResourceTests { /// Matches {0}, {1:N2}, {0,-8} — the index is what has to agree across languages. private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled); private static readonly IReadOnlyDictionary Neutral = ResourcesFor(CultureInfo.InvariantCulture); [Fact] public void Neutral_resources_exist() { Assert.NotEmpty(Neutral); // Every generated property is backed by a real entry, so `S.Foo` can never compile against // a key the resx no longer defines. var generated = typeof(Strings) .GetProperties(BindingFlags.Public | BindingFlags.Static) .Where(p => p.PropertyType == typeof(string) && p.Name != "Culture") .Select(p => p.Name) .ToList(); Assert.NotEmpty(generated); Assert.Empty(generated.Except(Neutral.Keys, StringComparer.Ordinal)); } [Theory] [MemberData(nameof(TranslatedCultures))] public void Every_string_is_translated(string culture) { var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture)); var missing = Neutral.Keys.Except(translated.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); Assert.True( missing.Count == 0, $"Strings.{culture}.resx is missing {missing.Count} key(s): {string.Join(", ", missing)}"); // An entry that exists but is blank renders as nothing at all — worse than falling back. var blank = translated.Where(e => string.IsNullOrWhiteSpace(e.Value)).Select(e => e.Key).Order(StringComparer.Ordinal).ToList(); Assert.True(blank.Count == 0, $"Strings.{culture}.resx has blank value(s): {string.Join(", ", blank)}"); } [Theory] [MemberData(nameof(TranslatedCultures))] public void No_translation_is_orphaned(string culture) { var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture)); // A key only the translation has is dead weight: nothing can reference it, because the // strongly-typed accessor is generated from the neutral resx alone. var orphans = translated.Keys.Except(Neutral.Keys, StringComparer.Ordinal).Order(StringComparer.Ordinal).ToList(); Assert.True( orphans.Count == 0, $"Strings.{culture}.resx defines {orphans.Count} key(s) the neutral resx does not: {string.Join(", ", orphans)}"); } [Theory] [MemberData(nameof(TranslatedCultures))] public void Placeholders_survive_translation(string culture) { var translated = ResourcesFor(CultureInfo.GetCultureInfo(culture)); // Loc.F feeds these to string.Format, so a placeholder dropped or invented in translation is // a FormatException or a silently missing number at runtime, in that language only. var broken = new List(); foreach (var (key, english) in Neutral) { if (!translated.TryGetValue(key, out var other)) { continue; } var expected = PlaceholderIndexes(english); var actual = PlaceholderIndexes(other); if (!expected.SetEquals(actual)) { broken.Add($"{key} (en: {{{string.Join(",", expected.Order())}}}, {culture}: {{{string.Join(",", actual.Order())}}})"); } } Assert.True(broken.Count == 0, $"Placeholder mismatch in Strings.{culture}.resx: {string.Join("; ", broken)}"); } [Fact] public void Supported_cultures_all_resolve() { foreach (var culture in Loc.SupportedCultures) { Assert.True(Loc.TryResolve(culture, out var resolved)); Assert.Equal(culture, resolved); // The picker labels itself with these, so an unnamed culture would render blank. Assert.False(string.IsNullOrWhiteSpace(Loc.DisplayName(culture))); } } [Theory] [InlineData("de-DE", "de")] [InlineData("de-AT", "de")] [InlineData("de_CH", "de")] [InlineData("EN-gb", "en")] public void Regional_variants_resolve_to_the_language_we_ship(string requested, string expected) { Assert.True(Loc.TryResolve(requested, out var resolved)); Assert.Equal(expected, resolved); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("fr")] [InlineData("klingon")] public void Untranslated_languages_fall_back_to_the_neutral_culture(string? requested) { Assert.False(Loc.TryResolve(requested, out var resolved)); Assert.Equal(Loc.SupportedCultures[0], resolved); } [Fact] public void No_string_is_defined_but_never_used() { // The compiler catches the other direction — S.Foo against a deleted key is a build error — // but a key nothing references compiles perfectly and quietly costs a translator work on // every language we ever add. Enum_* is exempt: those are reached by name from // DisplayNames' switch arms, which EnumDisplayNameTests covers instead. var root = FindRepositoryRoot(); if (root is null) { return; // Running detached from the source tree; the other tests still cover the catalogue. } var sources = new[] { Path.Combine(root, "src") } .SelectMany(dir => Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories)) .Where(f => (f.EndsWith(".cs", StringComparison.Ordinal) || f.EndsWith(".razor", StringComparison.Ordinal)) && !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) .Select(File.ReadAllText) .ToList(); Assert.NotEmpty(sources); var all = string.Join('\n', sources); var unused = Neutral.Keys .Where(k => !k.StartsWith("Enum_", StringComparison.Ordinal)) .Where(k => !Regex.IsMatch(all, $@"\b{Regex.Escape(k)}\b")) .Order(StringComparer.Ordinal) .ToList(); Assert.True(unused.Count == 0, $"{unused.Count} unused string(s): {string.Join(", ", unused)}"); } /// Walks up from the test binaries to the checkout, identified by the solution file. private static string? FindRepositoryRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "MeterVault.slnx"))) { dir = dir.Parent; } return dir?.FullName; } /// Every shipped language except the neutral one, which is the baseline being compared against. public static TheoryData TranslatedCultures() { var data = new TheoryData(); foreach (var culture in Loc.SupportedCultures.Skip(1)) { data.Add(culture); } return data; } private static HashSet PlaceholderIndexes(string value) => [.. PlaceholderPattern.Matches(value).Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))]; /// /// The entries one culture's resource set actually defines. tryParents: false is the /// whole point: with fallback on, a missing German string is indistinguishable from a present one. /// private static IReadOnlyDictionary ResourcesFor(CultureInfo culture) { var set = Strings.ResourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false); var entries = new Dictionary(StringComparer.Ordinal); if (set is null) { return entries; } foreach (DictionaryEntry entry in set) { if (entry.Key is string key && entry.Value is string value) { entries[key] = value; } } return entries; } }