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