i18n: ship the UI in English and German
ci / build-test (push) Failing after 35s

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
This commit is contained in:
Florian Schmidt
2026-08-13 16:36:25 +02:00
parent af786c7b28
commit bfa0b537ee
55 changed files with 5215 additions and 608 deletions
@@ -2,6 +2,7 @@ using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Import;
using MeterVault.Infrastructure.Persistence;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -111,6 +112,45 @@ public sealed class DashboardRenderTests(TimescaleFixture fx)
var meterPage = await (await client.GetAsync(new Uri($"/meters/{hausId}", UriKind.Relative)))
.Content.ReadAsStringAsync();
Assert.Contains("Add reading", meterPage, StringComparison.Ordinal);
// The same pages in German (SDD §12, M7). Real data rather than an empty instance, so
// this covers the labels that only exist once rows have rendered — the branch a
// smoke test against a bare database silently skips.
using var germanClient = factory.CreateClient();
germanClient.DefaultRequestHeaders.Add(
"Cookie",
CookieRequestCultureProvider.DefaultCookieName
+ "="
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
// Decoded, because Blazor entity-encodes non-ASCII: "Übersicht" ships as "Übersicht".
var germanOverview = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/", UriKind.Relative)));
Assert.Contains("lang=\"de\"", germanOverview, StringComparison.Ordinal);
Assert.Contains("Übersicht", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("This month", germanOverview, StringComparison.Ordinal);
Assert.DoesNotContain("Latest month with data", germanOverview, StringComparison.Ordinal);
// Meter names are user data: they stay exactly as imported, in either language. The
// meter list is where they render — the overview shows cost categories, not meters.
var germanMeters = System.Net.WebUtility.HtmlDecode(
await germanClient.GetStringAsync(new Uri("/meters", UriKind.Relative)));
Assert.Contains("Zähler Haus", germanMeters, StringComparison.Ordinal);
// ...while the meter's mode, which is an enum and not user data, is translated.
Assert.Contains("Zählerstand (kumulativ)", germanMeters, StringComparison.Ordinal);
Assert.DoesNotContain("CumulativeCounter", germanMeters, StringComparison.Ordinal);
foreach (var path in new[]
{
"/meters", "/trends", "/solar", "/consumables", "/import",
"/admin/tariffs", "/admin/energy-types", "/admin/categories",
"/admin/connectors", "/admin/settings", $"/meters/{hausId}",
$"/energy/{electricityTypeId}",
})
{
var response = await germanClient.GetAsync(new Uri(path, UriKind.Relative));
response.EnsureSuccessStatusCode();
}
}
finally
{
@@ -0,0 +1,180 @@
using System.Net;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc.Testing;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// The language picker's endpoint and the culture the middleware hands each render (SDD §12, M7).
/// </summary>
/// <remarks>
/// <para>
/// A Blazor Server circuit is fixed to the culture of the request that opened it, so switching
/// language cannot be an interactive state change — it is a redirect that writes a cookie and forces
/// a reload. That makes <c>/culture/set</c> a redirector taking its target from the query string,
/// which is the exact shape of an open redirect, so the off-site cases below are load-bearing.
/// </para>
/// <para>
/// Nothing here touches the database: no page on these paths queries, and startup migration is off,
/// so the connection string only has to parse. They run without Docker.
/// </para>
/// </remarks>
public sealed class CultureEndpointTests : IDisposable
{
private const string UnusedConnection = "Host=localhost;Port=1;Database=metervault;Username=none;Password=none";
/// <summary>Renders the full layout (app bar + nav) without needing any data.</summary>
private static readonly Uri LayoutOnlyPage = new("/admin/settings", UriKind.Relative);
private readonly List<MeterVaultAppFactory> _factories = [];
[Fact]
public async Task Setting_a_supported_culture_stores_the_cookie_and_returns_the_user_to_the_page()
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri("/culture/set?culture=de&redirectUri=%2Fmeters%2F7", UriKind.Relative));
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Equal("/meters/7", response.Headers.Location?.OriginalString);
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
}
[Fact]
public async Task A_regional_variant_is_stored_as_the_language_we_ship()
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri("/culture/set?culture=de-AT&redirectUri=%2F", UriKind.Relative));
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
}
[Theory]
[InlineData("fr")]
[InlineData("")]
[InlineData("../../etc/passwd")]
public async Task A_language_we_do_not_ship_is_refused_rather_than_stored(string culture)
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri($"/culture/set?culture={Uri.EscapeDataString(culture)}&redirectUri=%2F", UriKind.Relative));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Null(CultureCookie(response));
}
[Theory]
[InlineData("https://evil.example/phish")]
[InlineData("//evil.example/phish")]
[InlineData("/\\evil.example/phish")]
[InlineData("")]
public async Task An_off_site_redirect_target_lands_on_the_dashboard_instead(string redirectUri)
{
using var client = RedirectlessClient();
using var response = await client.GetAsync(
new Uri($"/culture/set?culture=de&redirectUri={Uri.EscapeDataString(redirectUri)}", UriKind.Relative));
Assert.Equal(HttpStatusCode.Found, response.StatusCode);
Assert.Equal("/", response.Headers.Location?.OriginalString);
}
[Fact]
public async Task The_culture_cookie_decides_the_language_of_the_rendered_page()
{
using var client = Factory().CreateClient();
client.DefaultRequestHeaders.Add(
"Cookie",
CookieRequestCultureProvider.DefaultCookieName
+ "="
+ Uri.EscapeDataString(CookieRequestCultureProvider.MakeCookieValue(new RequestCulture("de"))));
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal);
Assert.Contains("Übersicht", html, StringComparison.Ordinal);
Assert.DoesNotContain("Overview", html, StringComparison.Ordinal);
}
[Fact]
public async Task Without_a_cookie_the_instance_default_locale_applies()
{
using var client = Factory(locale: "de").CreateClient();
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"de\"", html, StringComparison.Ordinal);
Assert.Contains("Übersicht", html, StringComparison.Ordinal);
}
[Fact]
public async Task English_is_the_shipped_default_when_nothing_is_configured()
{
using var client = Factory().CreateClient();
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal);
Assert.Contains("Overview", html, StringComparison.Ordinal);
}
[Fact]
public async Task An_unshipped_default_locale_degrades_to_english_instead_of_failing_to_boot()
{
using var client = Factory(locale: "fr").CreateClient();
var html = await RenderedTextAsync(client, LayoutOnlyPage);
Assert.Contains("lang=\"en\"", html, StringComparison.Ordinal);
Assert.Contains("Overview", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_first_visit_pins_the_negotiated_culture_into_the_cookie()
{
// Otherwise a reader whose browser asks for German is served German, but opens the picker
// to find English ticked — the cookie is the only thing either side agrees to read.
using var client = RedirectlessClient();
client.DefaultRequestHeaders.Add("Accept-Language", "de-DE,de;q=0.9,en;q=0.8");
using var response = await client.GetAsync(LayoutOnlyPage);
Assert.Contains("c%3Dde%7Cuic%3Dde", CultureCookie(response), StringComparison.Ordinal);
}
public void Dispose()
{
foreach (var factory in _factories)
{
factory.Dispose();
}
}
/// <summary>
/// Blazor entity-encodes every non-ASCII character it renders, so "Übersicht" arrives as
/// "&amp;#xDC;bersicht" — decoding first is what lets these assertions read like the UI does.
/// </summary>
private static async Task<string> RenderedTextAsync(HttpClient client, Uri page) =>
WebUtility.HtmlDecode(await client.GetStringAsync(page));
private static string? CultureCookie(HttpResponseMessage response) =>
response.Headers.TryGetValues("Set-Cookie", out var cookies)
? cookies.FirstOrDefault(c =>
c.StartsWith(CookieRequestCultureProvider.DefaultCookieName, StringComparison.Ordinal))
: null;
private MeterVaultAppFactory Factory(string? locale = null)
{
var factory = new MeterVaultAppFactory(UnusedConnection, configureApiKey: false) { Locale = locale };
_factories.Add(factory);
return factory;
}
private HttpClient RedirectlessClient() =>
Factory().CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
}
@@ -0,0 +1,119 @@
using System.Globalization;
using MeterVault.App.Localization;
using MeterVault.Core.Domain;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// Every domain enum value the UI renders has wording in every language (SDD §12, M7).
/// </summary>
/// <remarks>
/// <see cref="DisplayNames"/> ends each mapping with a fallback arm that returns the bare identifier,
/// so adding an enum value can never throw mid-render on a dashboard. The cost of that safety is that
/// a forgotten value degrades silently to English — these tests are what make it loud instead, by
/// demanding a matching <c>Enum_&lt;Type&gt;_&lt;Value&gt;</c> resource for every declared value.
/// </remarks>
public sealed class EnumDisplayNameTests
{
[Fact]
public void Every_localized_enum_value_has_a_resource_in_every_language()
{
var missing = new List<string>();
foreach (var type in DisplayNames.LocalizedEnums)
{
foreach (var name in Enum.GetNames(type))
{
// None is the empty bitmask, deliberately rendered as nothing at all.
if (type == typeof(ReadingFlags) && name == nameof(ReadingFlags.None))
{
continue;
}
var key = $"Enum_{type.Name}_{name}";
foreach (var culture in Loc.SupportedCultures)
{
var value = Strings.ResourceManager.GetString(key, CultureInfo.GetCultureInfo(culture));
if (string.IsNullOrWhiteSpace(value))
{
missing.Add($"{key} [{culture}]");
}
}
}
}
Assert.True(missing.Count == 0, $"Missing enum display names: {string.Join(", ", missing)}");
}
[Fact]
public void Display_names_are_translated_rather_than_echoing_the_identifier()
{
// Not every value can differ — "Tasmota", "Bonus" and "Global" are the same word in German —
// but if a whole enum came back as its own identifiers, the mapping was never written.
var untranslated = new List<string>();
foreach (var type in DisplayNames.LocalizedEnums)
{
var names = Enum.GetNames(type);
var translated = 0;
foreach (var name in names)
{
var german = Strings.ResourceManager.GetString($"Enum_{type.Name}_{name}", CultureInfo.GetCultureInfo("de"));
if (german is not null && !string.Equals(german, name, StringComparison.Ordinal))
{
translated++;
}
}
if (translated == 0)
{
untranslated.Add(type.Name);
}
}
Assert.True(untranslated.Count == 0, $"Enums with no German wording at all: {string.Join(", ", untranslated)}");
}
[Theory]
[InlineData("en", "Consumption")]
[InlineData("de", "Verbrauch")]
public void The_reader_s_language_decides_the_wording(string culture, string expected) =>
Assert.Equal(expected, WithUiCulture(culture, () => ConsumptionKind.Consumption.Display()));
[Fact]
public void A_bitmask_lists_the_flags_it_actually_carries()
{
// Blank rather than "None": the readings table has a flag column that is empty on almost
// every row, and printing a word down the whole page is noise, not information.
Assert.Equal(string.Empty, ReadingFlags.None.Display());
var both = WithUiCulture("de", () => (ReadingFlags.CounterReset | ReadingFlags.MeterSwap).Display());
Assert.Equal("Zählerreset, Zählerwechsel", both);
Assert.Equal("Meter swap", WithUiCulture("en", () => ReadingFlags.MeterSwap.Display()));
}
[Fact]
public void An_undeclared_enum_value_degrades_to_its_identifier_instead_of_throwing()
{
// Guards the fallback arm itself: a value cast in from the database (or a future migration)
// must not take a dashboard down.
var unknown = (MeterMode)999;
Assert.Equal("999", unknown.Display());
}
private static T WithUiCulture<T>(string culture, Func<T> body)
{
var previous = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
return body();
}
finally
{
CultureInfo.CurrentUICulture = previous;
}
}
}
@@ -0,0 +1,80 @@
using System.Globalization;
using MeterVault.App;
namespace MeterVault.Integration.Tests.Localization;
/// <summary>
/// <see cref="Format"/> formats against the reader's culture rather than a fixed de-DE (SDD §12, M7).
/// </summary>
/// <remarks>
/// The importer's de-DE parsing is deliberately untouched by this: that dialect is a property of the
/// spreadsheet files, not of who is looking at the dashboard, and <c>GermanParsingTests</c> pins it.
/// </remarks>
public sealed class FormatCultureTests
{
[Fact]
public void Digit_grouping_follows_the_reader()
{
Assert.Equal("1.234,5", WithCulture("de", () => Format.Number(1234.5, 1)));
Assert.Equal("1,234.5", WithCulture("en", () => Format.Number(1234.5, 1)));
Assert.Equal("2.940", WithCulture("de", () => Format.Number(2940)));
Assert.Equal("2,940", WithCulture("en", () => Format.Number(2940)));
}
[Fact]
public void The_currency_symbol_stays_the_instances_own()
{
// Only the grouping is localized. The figures are in the instance's configured currency, so
// an English reader must see the same money written their way — not relabelled as dollars.
Assert.Equal("1.234,50 €", WithCulture("de", () => Format.Euro(1234.5)));
Assert.Equal("1,234.50 €", WithCulture("en", () => Format.Euro(1234.5)));
}
[Fact]
public void Percentages_keep_their_explicit_sign()
{
Assert.Equal("+12,4 %", WithCulture("de", () => Format.Percent(12.4)));
Assert.Equal("+12.4 %", WithCulture("en", () => Format.Percent(12.4)));
Assert.Equal("-7,2 %", WithCulture("de", () => Format.Percent(-7.2)));
Assert.Equal("-7.2 %", WithCulture("en", () => Format.Percent(-7.2)));
}
[Fact]
public void Month_labels_are_written_in_the_readers_language()
{
var march = new DateOnly(2025, 3, 1);
var german = WithCulture("de", () => Format.MonthLabel(march));
var english = WithCulture("en", () => Format.MonthLabel(march));
// Asserting the exact German abbreviation would pin us to one ICU version ("Mrz" vs "Mär"),
// so assert what actually matters: the label is culture-sensitive, not invariant.
Assert.Equal("Mar 25", english);
Assert.NotEqual(english, german);
Assert.EndsWith("25", german, StringComparison.Ordinal);
}
[Fact]
public void Direction_icons_are_language_neutral()
{
Assert.Equal("▲", Format.DirectionIcon(1));
Assert.Equal("▼", Format.DirectionIcon(-1));
Assert.Equal("—", Format.DirectionIcon(0));
}
private static T WithCulture<T>(string culture, Func<T> body)
{
var previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
return body();
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
}
@@ -0,0 +1,220 @@
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;
}
}
@@ -30,7 +30,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Equal("Consumption", view!.Label);
Assert.Equal(ConsumptionKind.Consumption, view!.Kind);
Assert.Equal(30d, view.MonthToDate, 3);
Assert.Equal(100d, view.LastMonth, 3);
Assert.Equal(130d, view.YearToDate, 3);
@@ -55,7 +55,7 @@ public sealed class MeterPeriodServiceTests(TimescaleFixture fx)
var view = await NewService().GetAsync(meterId);
Assert.NotNull(view);
Assert.Equal("Generation", view!.Label);
Assert.Equal(ConsumptionKind.Generation, view!.Kind);
Assert.Equal(42d, view.MonthToDate, 3);
await CleanupAsync(db, meterId);
@@ -11,8 +11,16 @@ public sealed class MeterVaultAppFactory(string connectionString, bool configure
{
public const string ApiKey = "test-api-key";
/// <summary>Overrides <c>MeterVault__Locale</c>, the instance's default UI language.</summary>
public string? Locale { get; init; }
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
if (Locale is not null)
{
builder.UseSetting("MeterVault:Locale", Locale);
}
builder.UseEnvironment("Testing");
builder.UseSetting("ConnectionStrings:Default", connectionString);
builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false");