using MeterVault.App; using MeterVault.App.TariffEditing; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Integration.Tests.Analysis; namespace MeterVault.Integration.Tests.Editor; /// /// The tariff editor's link and unit rules (D-37, D-52): what /admin/tariffs?scope=&id=&component=&from=&action=new /// asks for and lists, and which units a save accepts for the scope's normalized units — pure, no database. /// public sealed class TariffEditingTests { // ------------------------------------------------------------------------------------------------ value (D-38) [Fact] public void A_new_tariff_without_a_value_cannot_be_saved_while_a_typed_zero_is_a_deliberate_free_price() { // D-38: an explicit zero tariff is a valid zero — so it must be typed, never the default of an untouched field. The // deep link that explains a missing price must not turn it into a free period with one click. Assert.Equal(TariffValueVerdict.Missing, TariffValue.Check(null, TariffComponent.UnitPrice)); Assert.True(TariffValue.Check(null, TariffComponent.BasePrice).BlocksSave()); var zero = TariffValue.Check(0, TariffComponent.UnitPrice); Assert.Equal(TariffValueVerdict.FreeOfCharge, zero); Assert.False(zero.BlocksSave()); Assert.Equal(TariffValueVerdict.FreeOfCharge, TariffValue.Check(0, TariffComponent.BasePrice)); Assert.Equal(TariffValueVerdict.Valid, TariffValue.Check(0.31, TariffComponent.UnitPrice)); Assert.Equal(TariffValueVerdict.Valid, TariffValue.Check(0, TariffComponent.Tax)); AnalysisUiTestData.In("en", () => { Assert.Equal("A price of 0 makes this period free of charge.", TariffValue.Note(TariffValueVerdict.FreeOfCharge)); Assert.Equal("Enter a value.", TariffValue.Note(TariffValueVerdict.Missing)); Assert.Null(TariffValue.Note(TariffValueVerdict.Valid)); }); } // ------------------------------------------------------------------------------------------------ deep link (D-52) [Fact] public void A_missing_price_link_round_trips_into_a_prefilled_new_tariff() { var url = TariffLinks.New(TariffScope.Meter, 12, TariffComponent.UnitPrice, new DateOnly(2024, 1, 17)); var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(new Uri("http://x" + url).Query); var link = TariffDeepLink.Parse(query["scope"], query["id"], query["component"], query["from"], query["action"]); Assert.Equal(new TariffDeepLink(TariffScope.Meter, 12, TariffComponent.UnitPrice, new DateOnly(2024, 1, 1), OpenNew: true), link); Assert.True(link.HasScope); } [Theory] [InlineData("type", "3", "feed-in", "2026-02-01", "new", TariffScope.EnergyType, 3, TariffComponent.FeedIn, true)] [InlineData("Meter", "7", "UnitPrice", null, null, TariffScope.Meter, 7, TariffComponent.UnitPrice, false)] [InlineData("global", "5", "base-price", "2025-06-01", "NEW", TariffScope.Global, null, TariffComponent.BasePrice, true)] public void Scope_component_date_and_action_are_read_case_insensitively( string scope, string id, string component, string? from, string? action, TariffScope expectedScope, int? expectedId, TariffComponent expectedComponent, bool openNew) { var link = TariffDeepLink.Parse(scope, id, component, from, action); Assert.Equal(expectedScope, link.Scope); Assert.Equal(expectedId, link.ScopeId); // global takes no id, whatever the link says Assert.Equal(expectedComponent, link.Component); Assert.Equal(from is null ? null : DateOnly.Parse(from, System.Globalization.CultureInfo.InvariantCulture), link.From); Assert.Equal(openNew, link.OpenNew); } [Theory] [InlineData("planet", "3", "unit-price", "2026-01-01", "new")] [InlineData("type", null, "unit-price", "2026-01-01", "new")] [InlineData("meter", "0", "unit-price", "2026-01-01", "new")] [InlineData("meter", "-4", "unit-price", "2026-01-01", "new")] [InlineData("meter", "abc", "unit-price", "2026-01-01", "new")] public void A_scope_without_a_usable_target_scopes_nothing(string? scope, string? id, string? component, string? from, string? action) { var link = TariffDeepLink.Parse(scope, id, component, from, action); Assert.Null(link.Scope); Assert.Null(link.ScopeId); Assert.False(link.HasScope); Assert.True(link.OpenNew); // the dialog still opens, just not prefilled with a scope Assert.Equal(TariffComponent.UnitPrice, link.Component); } [Fact] public void Unknown_tokens_are_ignored_one_by_one() { var link = TariffDeepLink.Parse("meter", "4", "rebate", "31.12.2026", "open"); Assert.Equal(TariffScope.Meter, link.Scope); Assert.Equal(4, link.ScopeId); Assert.Null(link.Component); Assert.Null(link.From); Assert.False(link.OpenNew); Assert.Equal(TariffDeepLink.None, TariffDeepLink.Parse(null, null, null, null, null)); } [Fact] public void A_scoped_list_shows_what_can_price_the_scope_in_precedence_order() { // Meter 12 and 13 are electricity (type 1), meter 20 is water (type 2). int? TypeOf(int meter) => meter switch { 12 or 13 => 1, 20 => 2, _ => null }; var global = Tariff(TariffScope.Global, null); var electricity = Tariff(TariffScope.EnergyType, 1); var water = Tariff(TariffScope.EnergyType, 2); var meter12 = Tariff(TariffScope.Meter, 12); var meter13 = Tariff(TariffScope.Meter, 13); var meter20 = Tariff(TariffScope.Meter, 20); Tariff[] all = [global, electricity, water, meter12, meter13, meter20]; IEnumerable Listed(TariffDeepLink link) => all.Where(t => link.Lists(t, TypeOf)); Assert.Equal([global, electricity, meter12], Listed(TariffDeepLink.Parse("meter", "12", null, null, null))); Assert.Equal([global, electricity, meter12, meter13], Listed(TariffDeepLink.Parse("type", "1", null, null, null))); Assert.Equal([global], Listed(TariffDeepLink.Parse("global", null, null, null, null))); Assert.Equal(all, Listed(TariffDeepLink.None)); } // ------------------------------------------------------------------------------------------------ unit check (D-37) [Theory] [InlineData("EUR/kWh", "kWh", TariffUnitVerdictKind.Fits)] [InlineData("ct/kWh", "kWh", TariffUnitVerdictKind.Fits)] [InlineData("EUR/MWh", "kWh", TariffUnitVerdictKind.Fits)] [InlineData("EUR/100 L", "L", TariffUnitVerdictKind.Fits)] [InlineData("EUR/m3", "m³", TariffUnitVerdictKind.Fits)] [InlineData("EUR/kWh brutto", "kWh", TariffUnitVerdictKind.Fits)] [InlineData("EUR/kWh", "m³", TariffUnitVerdictKind.Blocked)] [InlineData("EUR/month", "kWh", TariffUnitVerdictKind.Blocked)] [InlineData("USD/kWh", "kWh", TariffUnitVerdictKind.Blocked)] [InlineData("pauschal", "kWh", TariffUnitVerdictKind.Warning)] public void A_unit_price_must_be_quoted_per_the_unit_the_scope_bills(string unit, string meterUnit, TariffUnitVerdictKind expected) { var verdict = TariffUnitCheck.Check(unit, TariffComponent.UnitPrice, [new TariffUnitTarget("Netz", meterUnit)], "EUR"); Assert.Equal(expected, verdict.Kind); Assert.Equal(expected == TariffUnitVerdictKind.Blocked, verdict.Blocks); } [Theory] [InlineData("EUR/month", TariffUnitVerdictKind.Fits)] [InlineData("EUR/Tag", TariffUnitVerdictKind.Fits)] [InlineData("€/Jahr inkl. MwSt.", TariffUnitVerdictKind.Fits)] [InlineData("EUR/Quartal", TariffUnitVerdictKind.Fits)] [InlineData("EUR/kWh", TariffUnitVerdictKind.Blocked)] [InlineData("EUR/2 Monate", TariffUnitVerdictKind.Blocked)] [InlineData("CHF/month", TariffUnitVerdictKind.Blocked)] [InlineData("monthly fee", TariffUnitVerdictKind.Warning)] public void A_base_price_must_be_quoted_per_day_month_quarter_or_year(string unit, TariffUnitVerdictKind expected) { Assert.Equal(expected, TariffUnitCheck.Check(unit, TariffComponent.BasePrice, [], "EUR").Kind); } [Theory] [InlineData(TariffComponent.Bonus)] [InlineData(TariffComponent.Discount)] [InlineData(TariffComponent.Tax)] public void Bonus_discount_and_tax_are_stored_but_say_they_are_not_applied(TariffComponent component) { var verdict = TariffUnitCheck.Check("EUR/kWh", component, [new TariffUnitTarget("Wasser", "m³")], "EUR"); Assert.Equal(TariffUnitVerdictKind.NotApplied, verdict.Kind); Assert.False(verdict.Blocks); var lines = AnalysisUiTestData.In("en", () => TariffUnitCheck.Describe(verdict, "EUR")); Assert.Equal([(false, "Bonus, discount and tax tariffs are stored but not applied to costs yet.")], lines); } [Fact] public void A_global_price_over_several_units_fits_some_and_warns_about_the_rest() { TariffUnitTarget[] targets = [new("Netz", "kWh"), new("Wasser", "m³")]; var verdict = TariffUnitCheck.Check("EUR/kWh", TariffComponent.UnitPrice, targets, "EUR"); Assert.Equal(TariffUnitVerdictKind.Warning, verdict.Kind); Assert.Equal(TariffUnitProblem.PartlyFits, verdict.Problem); var lines = AnalysisUiTestData.In("en", () => TariffUnitCheck.Describe(verdict, "EUR")); Assert.Equal( [(false, "Read as EUR per kWh."), (false, "Fits Netz (kWh)."), (true, "Does not fit Wasser, which measures in m³.")], lines); } [Fact] public void The_verdict_reads_in_german_too() { var blocked = TariffUnitCheck.Check("EUR/kWh", TariffComponent.UnitPrice, [new TariffUnitTarget("Zähler Wasser", "m³")], "EUR"); var monthly = TariffUnitCheck.Check("EUR/month", TariffComponent.BasePrice, [], "EUR"); AnalysisUiTestData.In("de", () => { Assert.Equal( [(false, "Gelesen als EUR pro kWh."), (true, "Passt nicht zu Zähler Wasser (gemessen in m³).")], TariffUnitCheck.Describe(blocked, "EUR")); Assert.Equal([(false, "Gelesen als EUR pro Monat, verteilt auf die Tage dieses Zeitraums.")], TariffUnitCheck.Describe(monthly, "EUR")); }); } [Fact] public void Targets_are_what_the_scope_bills_by_normalized_unit() { // Electricity: House (total load), Grid (grid import, billed), PV (generation), Export (grid export). Water: one meter. Meter M(int id, string name, MeterMode mode, string unit, short type, string? role = null) => new() { Id = id, Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, Meta = role is null ? "{}" : MeterMeta.SetRole("{}", role), }; Meter[] meters = [ M(1, "House", MeterMode.CumulativeCounter, "kWh", 1, "total_load"), M(2, "Grid", MeterMode.CumulativeCounter, "kWh", 1, "grid_import"), M(3, "PV", MeterMode.GenerationCounter, "kWh", 1), M(4, "Export", MeterMode.CumulativeCounter, "kWh", 1, "grid_export"), M(5, "Water", MeterMode.CumulativeCounter, "m3", 2), ]; var catalog = AnalysisCatalog.Build(meters, [], [new MeterLink { FromMeterId = 2, ToMeterId = 1 }], [], AnalysisUiTestData.Berlin); (string, string)? TypeInfo(int id) => id switch { 1 => ("Strom", "kWh"), 2 => ("Wasser", "m³"), 3 => ("Gas", "kWh"), _ => null }; Assert.Equal([new TariffUnitTarget("Grid", "kWh")], TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 1, TariffComponent.UnitPrice, TypeInfo)); Assert.Equal([new TariffUnitTarget("Export", "kWh")], TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 1, TariffComponent.FeedIn, TypeInfo)); Assert.Equal([new TariffUnitTarget("Water", "m³")], TariffUnitCheck.TargetsFor(catalog, TariffScope.Meter, 5, TariffComponent.UnitPrice, TypeInfo)); Assert.Equal( [new TariffUnitTarget("Grid", "kWh"), new TariffUnitTarget("Water", "m³")], TariffUnitCheck.TargetsFor(catalog, TariffScope.Global, null, TariffComponent.UnitPrice, TypeInfo)); // A type that bills nothing yet is checked against its base unit; a base price has no quantity to check. Assert.Equal([new TariffUnitTarget("Gas", "kWh")], TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 3, TariffComponent.UnitPrice, TypeInfo)); Assert.Empty(TariffUnitCheck.TargetsFor(catalog, TariffScope.EnergyType, 1, TariffComponent.BasePrice, TypeInfo)); Assert.Empty(TariffUnitCheck.TargetsFor(catalog, TariffScope.Meter, 99, TariffComponent.UnitPrice, TypeInfo)); // Nothing billed at all: the unit is read, and the check says it had nothing to compare with. var none = TariffUnitCheck.Check("EUR/kWh", TariffComponent.FeedIn, [], "EUR"); Assert.Equal(TariffUnitProblem.NoTargets, none.Problem); Assert.False(none.Blocks); } private static Tariff Tariff(TariffScope scope, int? id) => new() { ScopeType = scope, ScopeId = id, Component = TariffComponent.UnitPrice, Unit = "EUR/kWh", Value = 0.3 }; }