Files
Florian Schmidt bfa0b537ee
ci / build-test (push) Failing after 35s
i18n: ship the UI in English and German
The last open item on the M7 list. Number and currency formatting was
already locale-aware, but every string in the UI was an English literal,
so a German instance read half in each language -- German data, English
chrome. This translates all of it and adds the machinery to keep it
translated.

Strings live in Localization/Strings.resx (English, neutral) and
Strings.de.resx. The neutral file generates a strongly-typed accessor at
build time, aliased as S in _Imports.razor, so components reference
compiled properties -- @S.Common_Save, not a string key. That choice is
the point: across 4,500 lines of markup, a key lookup that silently
falls back to its own name is a defect you find in production, while a
renamed property is a build error. Generation runs in MSBuild rather
than the IDE designer, so dotnet build alone reproduces it anywhere.

Resource fallback is the hazard here. Ask for a key the German satellite
lacks and ResourceManager quietly serves the English one -- correct at
runtime, disastrous at release time, because a half-translated build
looks perfectly healthy. StringResourceTests reads each satellite with
tryParents: false, which is the only way to see what one actually
contains, and fails on a missing or blank translation, a placeholder
that changed arity, an orphan, or a key nothing references.

Three things needed more than substitution:

- Domain enums reached the screen as bare identifiers. They stay bare in
  the model -- they are persisted as text and appear in the REST API, so
  their names are part of the data contract -- and DisplayNames is now
  the single place that decides how each value is spoken. Every arm ends
  in a fallback returning the identifier, so a value added later cannot
  throw mid-render; EnumDisplayNameTests is what stops that safety net
  quietly becoming the shipping behaviour.

- Infrastructure was writing display text: FlowService's "Other (X)",
  MeterPeriodView's "Generation"/"Consumption", the HA connection-test
  verdicts, the updater's snackbar, the CSV importer's row warnings.
  Each now returns an outcome value and the UI supplies the words, which
  is where the reader's language is known. Diagnostics that are not ours
  -- an HTTP status, systemd's stderr, an exception message -- are passed
  through untranslated, and every English summary is kept alongside the
  outcome so log lines never move with the UI language. The UpdateRunner
  change is additive only; no gate was touched.

- Importer warnings carry their arguments rather than a finished
  sentence, so the numbers inside them pick up the reader's grouping. A
  register that reads 2.940,19 everywhere else must not read 2940.19
  only inside a warning.

Switching language is a redirect through /culture/set followed by a full
reload, not an interactive state change: a Blazor Server circuit is fixed
to the culture of the request that opened it. That makes the endpoint a
redirector taking its target from the query string, so anything but a
local path is refused rather than followed. Preference order is the
cookie, then Accept-Language, then MeterVault__Locale -- an instance can
be pinned to one language and a reader can still switch.

Locale keeps its documented default of "en". Format now follows
CurrentCulture instead of a hardcoded de-DE, so an instance with nothing
configured and a browser asking for English will show English number
formatting where it previously showed German; set MeterVault__Locale=de
to pin the old behaviour. The importer's de-DE parsing is untouched and
stays that way -- that dialect is a property of the spreadsheets, not of
whoever is looking at the dashboard.

Anything that comes from the database -- meter names, energy-type display
names, category names -- is user data and is never translated.

Claude-Session: https://claude.ai/code/session_0112ezeWqaZ85kTj5bYu9JHx
2026-08-13 16:36:25 +02:00

221 lines
8.7 KiB
C#

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;
/// <summary>
/// Guards the UI string catalogue (SDD §12, M7).
/// </summary>
/// <remarks>
/// Resource lookup fails soft by design — ask for a key the German satellite doesn't carry and
/// <see cref="ResourceManager"/> 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 <c>tryParents: false</c>, 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.
/// </remarks>
public sealed class StringResourceTests
{
/// <summary>Matches {0}, {1:N2}, {0,-8} — the index is what has to agree across languages.</summary>
private static readonly Regex PlaceholderPattern = new(@"\{(\d+)(?:[,:][^}]*)?\}", RegexOptions.Compiled);
private static readonly IReadOnlyDictionary<string, string> 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<string>();
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)}");
}
/// <summary>Walks up from the test binaries to the checkout, identified by the solution file.</summary>
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;
}
/// <summary>Every shipped language except the neutral one, which is the baseline being compared against.</summary>
public static TheoryData<string> TranslatedCultures()
{
var data = new TheoryData<string>();
foreach (var culture in Loc.SupportedCultures.Skip(1))
{
data.Add(culture);
}
return data;
}
private static HashSet<int> PlaceholderIndexes(string value) =>
[.. PlaceholderPattern.Matches(value).Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))];
/// <summary>
/// The entries one culture's resource set actually defines. <c>tryParents: false</c> is the
/// whole point: with fallback on, a missing German string is indistinguishable from a present one.
/// </summary>
private static IReadOnlyDictionary<string, string> ResourcesFor(CultureInfo culture)
{
var set = Strings.ResourceManager.GetResourceSet(culture, createIfNotExists: true, tryParents: false);
var entries = new Dictionary<string, string>(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;
}
}