using MeterVault.Core.Analysis.Quantities; namespace MeterVault.Core.Tests.Analysis; public sealed class UnitsTests { public static TheoryData AliasTableEntries { get { var data = new TheoryData(); foreach (var entry in Units.AliasTable) { data.Add(entry.Alias, entry.Info.Symbol, entry.CaseSensitive); } return data; } } [Theory] // Energy [InlineData("kWh", "kWh")] [InlineData("kwh", "kWh")] [InlineData("KWH", "kWh")] [InlineData("KWh", "kWh")] [InlineData(" kWh ", "kWh")] [InlineData("k Wh", "kWh")] [InlineData("Kilowattstunden", "kWh")] [InlineData("kilowatt-hour", "kWh")] [InlineData("Kilowatt hours", "kWh")] [InlineData("Wh", "Wh")] [InlineData("wh", "Wh")] [InlineData("Wattstunden", "Wh")] [InlineData("MWh", "MWh")] [InlineData("MWH", "MWh")] [InlineData("Mwh", "MWh")] [InlineData("Megawattstunde", "MWh")] [InlineData("mWh", "mWh")] [InlineData("GWh", "GWh")] [InlineData("MJ", "MJ")] [InlineData("gj", "GJ")] // Power [InlineData("W", "W")] [InlineData("w", "W")] [InlineData("Watt", "W")] [InlineData("kW", "kW")] [InlineData("kw", "kW")] [InlineData("KW", "kW")] [InlineData("Kilowatt", "kW")] [InlineData("MW", "MW")] [InlineData("Mw", "MW")] [InlineData("mW", "mW")] // Volume [InlineData("m3", "m³")] [InlineData("M3", "m³")] [InlineData("m³", "m³")] [InlineData("cbm", "m³")] [InlineData("CBM", "m³")] [InlineData("m^3", "m³")] [InlineData("Kubikmeter", "m³")] [InlineData("cubic metre", "m³")] [InlineData("l", "L")] [InlineData("L", "L")] [InlineData("Liter", "L")] [InlineData("liter", "L")] [InlineData("Litre", "L")] [InlineData("litres", "L")] [InlineData("ltr", "L")] [InlineData("dm3", "L")] [InlineData("hl", "hL")] [InlineData("Hektoliter", "hL")] // Time [InlineData("h", "h")] [InlineData("H", "h")] [InlineData("hour", "h")] [InlineData("hours", "h")] [InlineData("hrs", "h")] [InlineData("Std", "h")] [InlineData("Std.", "h")] [InlineData("Stunden", "h")] [InlineData("Betriebsstunden", "h")] [InlineData("min", "min")] [InlineData("Minuten", "min")] [InlineData("s", "s")] // Mass [InlineData("kg", "kg")] [InlineData("KG", "kg")] [InlineData("t", "t")] [InlineData("Tonne", "t")] public void Every_alias_normalizes_to_its_canonical_symbol(string alias, string canonical) { Assert.Equal(canonical, Units.Normalize(alias)); } [Theory] [MemberData(nameof(AliasTableEntries))] public void Every_entry_of_the_alias_table_names_its_own_unit_however_it_is_capitalised( string alias, string symbol, bool caseSensitive) { var info = Units.Describe(symbol); Assert.NotNull(info); Assert.Equal(symbol, info.Symbol); Assert.Equal(symbol, Units.Normalize(symbol)); Assert.Equal(symbol, Units.Normalize(alias)); Assert.Equal(info, Units.Describe(alias)); Assert.Equal(info, Units.Describe($" {alias} ")); if (!caseSensitive) { // Typing a unit in capitals never changes what it means ("KWH", "LITER", "M³"). Assert.Equal(info, Units.Describe(alias.ToUpperInvariant())); } } [Fact] public void The_alias_table_holds_every_canonical_symbol_once_per_spelling() { var spellings = Units.AliasTable.Select(a => a.Alias).ToList(); Assert.Equal(spellings.Count, spellings.Distinct(StringComparer.Ordinal).Count()); Assert.All( new[] { "mWh", "Wh", "kWh", "MWh", "GWh", "MJ", "GJ", "mW", "W", "kW", "MW", "GW", "L", "hL", "m³", "g", "kg", "t", "h", "min", "s" }, symbol => Assert.Contains(symbol, spellings)); } [Fact] public void Milli_and_mega_are_told_apart_by_the_case_of_the_m() { // Home Assistant reports small sensors in mW/mWh; reading them as MW/MWh would be off by 10⁹. Assert.Equal(new UnitInfo("mW", UnitDimension.Power, 0.000_001), Units.Describe("mW")); Assert.Equal(new UnitInfo("MW", UnitDimension.Power, 1_000), Units.Describe("MW")); Assert.Equal(new UnitInfo("mWh", UnitDimension.Energy, 0.000_001), Units.Describe("mWh")); Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1_000), Units.Describe("MWh")); Assert.Equal(0.000_001, Units.ConversionFactor("mWh", "kWh")!.Value, 12); Assert.False(Units.AreSame("mW", "MW")); } [Theory] [InlineData("mwh")] [InlineData("mw")] [InlineData("mj")] [InlineData("mWH")] public void A_lower_case_m_on_a_mega_symbol_is_not_guessed(string unit) { Assert.Null(Units.Describe(unit)); Assert.Null(Units.ConversionFactor(unit, "kWh")); Assert.Null(Units.ConversionFactor(unit, "kW")); } [Theory] [InlineData(" Stk ", "stk")] [InlineData("STK", "stk")] [InlineData("Pellets", "pellets")] [InlineData("%", "%")] [InlineData("100 L", "100 l")] [InlineData("kWh (el)", "kwh (el)")] [InlineData("Nm³", "nm³")] public void An_unknown_unit_is_trimmed_and_folded_so_every_spelling_gives_one_key(string unit, string expected) { Assert.Equal(expected, Units.Normalize(unit)); Assert.Null(Units.Describe(unit)); } [Fact] public void Normalized_units_can_be_grouped_ordinally() { var meters = new[] { "Stk", "stk", "STK", "m3", "m³", "cbm", "kWh", "KWH" }; var groups = meters.GroupBy(Units.Normalize, StringComparer.Ordinal).Select(g => (g.Key, g.Count())).ToList(); Assert.Equal([("stk", 3), ("m³", 3), ("kWh", 2)], groups); } [Fact] public void The_unit_comparer_treats_every_spelling_of_a_unit_as_one_key() { var comparer = Units.Comparer; Assert.True(comparer.Equals("m3", "m³")); Assert.True(comparer.Equals("Stk", "stk")); Assert.True(comparer.Equals(null, " ")); Assert.False(comparer.Equals("kWh", "MWh")); Assert.False(comparer.Equals("mW", "MW")); Assert.Equal(comparer.GetHashCode("Stk"), comparer.GetHashCode("STK")); Assert.Equal(comparer.GetHashCode("m3"), comparer.GetHashCode("cbm")); Assert.Equal(3, new[] { "m3", "m³", "Stk", "stk", "kWh", "kwh" }.Distinct(comparer).Count()); var byUnit = new Dictionary(comparer) { ["m3"] = 1 }; Assert.True(byUnit.ContainsKey("Kubikmeter")); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void A_missing_unit_normalizes_to_empty(string? unit) { Assert.Equal(string.Empty, Units.Normalize(unit)); } [Theory] [InlineData("m3/h", "m³/h")] [InlineData("l/h", "L/h")] [InlineData("Liter / Std", "L/h")] [InlineData("kWh / h", "kWh/h")] [InlineData("Stk/h", "stk/h")] public void A_rate_normalizes_both_sides(string unit, string expected) { Assert.Equal(expected, Units.Normalize(unit)); } [Theory] [InlineData("m3", "m³")] [InlineData("cbm", "M3")] [InlineData("Liter", "l")] [InlineData("Stk", "stk")] [InlineData("L/h", "l/Std")] [InlineData("Kilowattstunden", "kWh")] public void Different_spellings_of_one_unit_are_the_same(string a, string b) { Assert.True(Units.AreSame(a, b)); } [Fact] public void Units_that_only_convert_into_each_other_are_not_the_same() { Assert.False(Units.AreSame("kWh", "MWh")); Assert.False(Units.AreSame("L", "m³")); } [Theory] [InlineData("kWh", "MWh")] [InlineData("Wh", "kWh")] [InlineData("L", "m3")] [InlineData("hl", "L")] [InlineData("kW", "MW")] [InlineData("h", "min")] [InlineData("kg", "t")] [InlineData("GJ", "kWh")] [InlineData("Stk", "Stk")] [InlineData("m3/h", "L/min")] [InlineData("mWh", "kWh")] public void Units_of_one_dimension_are_compatible(string a, string b) { Assert.True(Units.AreCompatible(a, b)); Assert.True(Units.AreCompatible(b, a)); } [Theory] [InlineData("kWh", "m³")] [InlineData("kW", "kWh")] [InlineData("h", "L")] [InlineData("kg", "L")] [InlineData("Stk", "kWh")] [InlineData("Stk", "Pellets")] [InlineData("", "kWh")] [InlineData("", "")] [InlineData("m³/h", "m³")] public void Units_of_different_dimensions_are_not_compatible(string a, string b) { Assert.False(Units.AreCompatible(a, b)); Assert.Null(Units.ConversionFactor(a, b)); } [Theory] [InlineData("MWh", "kWh", 1000)] [InlineData("kWh", "MWh", 0.001)] [InlineData("Wh", "kWh", 0.001)] [InlineData("kWh", "kWh", 1)] [InlineData("m3", "L", 1000)] [InlineData("L", "m³", 0.001)] [InlineData("hL", "L", 100)] [InlineData("L", "hL", 0.01)] [InlineData("t", "kg", 1000)] [InlineData("min", "h", 1 / 60d)] [InlineData("GJ", "kWh", 1000 / 3.6)] [InlineData("MJ", "kWh", 1 / 3.6)] [InlineData("Stk", "stk", 1)] [InlineData("m³/h", "L/min", 1000 / 60d)] [InlineData("Kilowattstunden", "Wh", 1000)] public void The_conversion_factor_expresses_an_amount_in_the_target_unit(string from, string to, double factor) { Assert.Equal(factor, Units.ConversionFactor(from, to)!.Value, 9); } [Theory] [InlineData("W", "Wh")] [InlineData("kW", "kWh")] [InlineData("kw", "kWh")] [InlineData("MW", "MWh")] [InlineData("GW", "GWh")] [InlineData("mW", "mWh")] [InlineData("L/h", "L")] [InlineData("m3/h", "m³")] [InlineData("l/Std", "L")] [InlineData("kWh/h", "kWh")] [InlineData("W/m²", "Wh/m²")] [InlineData("kW/m2", "kWh/m2")] [InlineData("kWh", "kWh")] [InlineData("L", "L")] [InlineData("Stk", "stk")] [InlineData("", "")] public void A_rate_per_hour_integrates_over_hours_to_its_quantity_unit(string rateUnit, string integrated) { Assert.Equal(integrated, Units.IntegratedOverHours(rateUnit)); } [Theory] [InlineData("L/min", "L/min")] [InlineData("l/Minute", "L/min")] [InlineData("m³/s", "m³/s")] [InlineData("L/Tag", "L/tag")] public void A_rate_over_another_time_keeps_its_rate_unit_because_the_normalizer_does_not_rescale_it( string rateUnit, string kept) { // The normalizer books value × elapsed hours: 10 L/min for an hour books 10, which is not 10 L. Assert.Equal(kept, Units.IntegratedOverHours(rateUnit)); Assert.Null(Units.ConversionFactor(Units.IntegratedOverHours(rateUnit), "L")); } [Theory] [InlineData("kW", true)] [InlineData("W", true)] [InlineData("mW", true)] [InlineData("L/h", true)] [InlineData("m3/Std", true)] [InlineData("W/m²", true)] [InlineData("L/min", false)] [InlineData("m³/s", false)] [InlineData("kWh", false)] [InlineData("Stk", false)] [InlineData("", false)] public void Only_power_and_explicit_per_hour_units_are_stated_per_hour_rates(string rateUnit, bool perHour) { Assert.Equal(perHour, Units.IsPerHourRate(rateUnit)); } [Fact] public void Describe_reports_dimension_and_scale_against_the_reference_unit() { var mwh = Units.Describe("MWH")!; var cubic = Units.Describe("cbm")!; Assert.Equal(new UnitInfo("MWh", UnitDimension.Energy, 1000), mwh); Assert.Equal(new UnitInfo("m³", UnitDimension.Volume, 1000), cubic); Assert.Null(Units.Describe("L/h")); } }