using System.Globalization; using MeterVault.App; using MeterVault.Core.Analysis; namespace MeterVault.Integration.Tests.Localization; /// /// formats against the reader's culture rather than a fixed de-DE (SDD §12, M7). /// /// /// 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 GermanParsingTests pins it. /// public sealed class FormatCultureTests { private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); [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 Money_is_in_the_configured_currency_written_the_readers_way() { // Deliberately rewritten (note §10): the symbol was a hard-coded euro; it is now the configured currency's // (D-43). Only the grouping is localized — an English reader sees the same money written their way, not // relabelled as dollars — and the placement stays "number, space, symbol". Assert.Equal("1.234,50 €", WithCulture("de", () => Format.Money(1234.5, "EUR"))); Assert.Equal("1,234.50 €", WithCulture("en", () => Format.Money(1234.5, "EUR"))); Assert.Equal("1,234.50 $", WithCulture("en", () => Format.Money(1234.5, "USD"))); Assert.Equal("1.234,50 £", WithCulture("de", () => Format.Money(1234.5, "gbp"))); Assert.Equal("1.234,50 CHF", WithCulture("de", () => Format.Money(1234.5, "CHF"))); Assert.Equal("12.00 SEK", WithCulture("en", () => Format.Money(12, " sek "))); Assert.Equal("12.00 €", WithCulture("en", () => Format.Money(12, null))); Assert.Equal("—", Format.Money(null, "EUR")); } [Fact] public void The_instance_currency_comes_from_the_settings() { var usd = new InstanceCurrency(Microsoft.Extensions.Options.Options.Create(new MeterVault.Infrastructure.Options.MeterVaultOptions { Currency = " USD " })); Assert.Equal("USD", usd.Code); Assert.Equal("$", usd.Symbol); Assert.Equal("1,234.50 $", WithCulture("en", () => usd.Format(1234.5))); Assert.Equal("—", usd.Format((double?)null)); var blank = new InstanceCurrency(Microsoft.Extensions.Options.Options.Create(new MeterVault.Infrastructure.Options.MeterVaultOptions { Currency = " " })); Assert.Equal("EUR", blank.Code); } [Fact] public void Signed_money_shows_its_sign_and_never_a_negative_zero() { Assert.Equal("+12,00 €", WithCulture("de", () => Format.MoneySigned(12, "EUR"))); Assert.Equal("-3,50 €", WithCulture("de", () => Format.MoneySigned(-3.5, "EUR"))); Assert.Equal("0,00 €", WithCulture("de", () => Format.MoneySigned(0.004, "EUR"))); Assert.Equal("0,00 €", WithCulture("de", () => Format.MoneySigned(-0.004, "EUR"))); Assert.Equal("0.00 €", WithCulture("en", () => Format.Money(-0.001, "EUR"))); } [Fact] public void Quantities_keep_their_unit_and_say_when_they_are_unknown() { Assert.Equal("—", Format.Quantity(null, "kWh")); Assert.Equal("—", Format.Quantity(double.NaN, "kWh")); Assert.Equal("1,235 kWh", WithCulture("en", () => Format.Quantity(1234.5, "kWh"))); Assert.Equal("5,3 m³", WithCulture("de", () => Format.Quantity(5.25, "m³"))); Assert.Equal("0.12 m³", WithCulture("en", () => Format.Quantity(0.123, "m³"))); Assert.Equal("0 kWh", WithCulture("en", () => Format.Quantity(0, "kWh"))); Assert.Equal("1,234.50 L", WithCulture("en", () => Format.Quantity(1234.5, "L", 2))); Assert.Equal("14", WithCulture("en", () => Format.Quantity(14, null))); } [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 A_change_always_states_the_difference_and_a_percentage_only_where_it_applies() { string Money(double v) => Format.Money(v, "EUR"); Assert.Equal("+20,00 € (+20,0 %)", WithBoth("de", () => Format.ChangeText(Change.Between(120, 100), Money))); Assert.Equal("-25.00 € (-25.0 %)", WithBoth("en", () => Format.ChangeText(Change.Between(75, 100), Money))); // No baseline, or a negative one: the difference stands, the percentage says it does not apply (D-08). Assert.Equal("+50,00 € (keine Prozentangabe möglich)", WithBoth("de", () => Format.ChangeText(Change.Between(50, 0), Money))); Assert.Equal("-2.00 € (percentage not applicable)", WithBoth("en", () => Format.ChangeText(Change.Between(-3, -1), Money))); Assert.Equal("0.00 € (+0.0 %)", WithBoth("en", () => Format.ChangeText(Change.Between(100, 100), Money))); // A missing value is no change at all, never "-100 %". Assert.Equal("—", Format.ChangeText(Change.Between(null, 100), Money)); Assert.Equal("—", Format.ChangePercent(Change.Unavailable)); } [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 Dates_and_ranges_name_the_year_where_it_is_needed() { var aug = new DateOnly(2026, 8, 19); var sep = new DateOnly(2026, 9, 19); Assert.Equal("Sep 19, 2026", WithCulture("en", () => Format.Date(sep))); Assert.Equal("Sep 19", WithCulture("en", () => Format.Date(sep, includeYear: false))); Assert.Equal("Aug 19 – Sep 19, 2026", WithCulture("en", () => Format.DateRange(aug, sep))); Assert.Equal("Dec 1, 2025 – Jan 31, 2026", WithCulture("en", () => Format.DateRange(new DateOnly(2025, 12, 1), new DateOnly(2026, 1, 31)))); Assert.Equal("Aug 19 – Sep 19", WithCulture("en", () => Format.DateRange(aug, sep, includeYear: false))); Assert.Equal("Sep 19, 2026", WithCulture("en", () => Format.DateRange(sep, sep))); // German day-first order, whatever the ICU month abbreviation. var german = WithCulture("de", () => Format.Date(sep)); Assert.StartsWith("19.", german, StringComparison.Ordinal); Assert.EndsWith("2026", german, StringComparison.Ordinal); } [Fact] public void A_period_shows_the_dates_it_actually_covers() { var now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)); var twelve = PeriodResolver.Resolve(PeriodPreset.Last12Months, null, null, now, Berlin); Assert.Equal("Oct 1, 2025 – Sep 19, 2026", WithCulture("en", () => Format.PeriodRange(twelve))); var none = PeriodResolver.Resolve(PeriodPreset.AllHistory, null, null, now, Berlin); Assert.Equal("—", Format.PeriodRange(none)); } [Fact] public void Bucket_labels_carry_the_year_across_years_and_real_dates_for_partial_units() { static AnalysisBucket Bucket(BucketSize size, DateOnly first, DateOnly end, DateOnly? nominal = null) => new(first, end, new DateTimeOffset(first.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), new DateTimeOffset(end.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), size, nominal); var september = Bucket(BucketSize.Month, new DateOnly(2026, 9, 1), new DateOnly(2026, 10, 1)); Assert.Equal("Sep", WithCulture("en", () => Format.BucketLabel(september, includeYear: false))); Assert.Equal("Sep 2026", WithCulture("en", () => Format.BucketLabel(september, includeYear: true))); // The current month cut at now is still that month; a month clipped by a custom range shows its days. var toDate = Bucket(BucketSize.Month, new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 20), new DateOnly(2026, 10, 1)); Assert.Equal("Sep 2026", WithCulture("en", () => Format.BucketLabel(toDate, includeYear: true))); var clipped = Bucket(BucketSize.Month, new DateOnly(2026, 1, 15), new DateOnly(2026, 2, 1)); Assert.Equal("Jan 15 – Jan 31, 2026", WithCulture("en", () => Format.BucketLabel(clipped, includeYear: true))); // A week always shows its real first and last day — a partial week never looks whole. var partialWeek = Bucket(BucketSize.Week, new DateOnly(2026, 9, 28), new DateOnly(2026, 10, 1)); Assert.Equal("Sep 28 – Sep 30", WithCulture("en", () => Format.BucketLabel(partialWeek, includeYear: false))); var yearEndWeek = Bucket(BucketSize.Week, new DateOnly(2025, 12, 29), new DateOnly(2026, 1, 5)); Assert.Equal("Dec 29, 2025 – Jan 4, 2026", WithCulture("en", () => Format.BucketLabel(yearEndWeek, includeYear: true))); Assert.Equal("Sep 19, 2026", WithCulture("en", () => Format.BucketLabel(Bucket(BucketSize.Day, new DateOnly(2026, 9, 19), new DateOnly(2026, 9, 20)), true))); Assert.Equal("2025", WithCulture("en", () => Format.BucketLabel(Bucket(BucketSize.Year, new DateOnly(2025, 1, 1), new DateOnly(2026, 1, 1)), true))); Assert.True(Format.SpansYears([yearEndWeek])); Assert.False(Format.SpansYears([september, toDate])); Assert.False(Format.SpansYears([])); } [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(string culture, Func body) { var previous = CultureInfo.CurrentCulture; try { CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture); return body(); } finally { CultureInfo.CurrentCulture = previous; } } /// Formatting and wording in one language, as a request gets them from the localization middleware. private static T WithBoth(string culture, Func body) { var previous = CultureInfo.CurrentUICulture; try { CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture); return WithCulture(culture, body); } finally { CultureInfo.CurrentUICulture = previous; } } }