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