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;
}
}