using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using static MeterVault.Core.Tests.Analysis.VirtualFixtures; namespace MeterVault.Core.Tests.Analysis; /// /// Validation on save and on read (D-26): syntax, references, loops through nested virtual meters with their path, /// and meaning — like is only added to like, a product of meters is an indicator with its own unit, and the cost /// rule fits the formula's shape. /// public sealed class VirtualValidatorTests { private static readonly MeterCatalog Catalog = new( [ .. SeededElectricity(), Physical(8, "Einspeisung", MeterMode.CumulativeCounter, QuantityKind.Export, "kWh"), Physical(9, "Brenner", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), Physical(10, "Gartenwasser", MeterMode.CumulativeCounter, QuantityKind.Consumption, "m3", energyType: 2), Virtual(13, "Unkonvertiert", QuantityKind.Generation, "kWh", expression: null), Virtual(14, "Kaputt", QuantityKind.Consumption, "kWh", expression: "m1 +"), Physical(19, "Brenner 2", MeterMode.RuntimeCounter, QuantityKind.Runtime, "h", energyType: 3), Virtual(20, "Autarkie", QuantityKind.Indicator, "%", expression: "m2 / m1"), Virtual(21, "Eigenverbrauchsquote", QuantityKind.Indicator, "%", expression: "m4 / m1"), ]); private static VirtualValidation Validate(string expression, QuantityKind? kind = null, string? unit = null, VirtualCostRule? costRule = null, int meterId = 100) => VirtualValidator.Validate(new VirtualDefinition(expression, kind, unit, costRule), meterId, Catalog); private static VirtualProblem Single(VirtualValidation validation) => Assert.Single(validation.Problems); [Fact] public void Summe_solar_as_a_sum_of_two_generation_meters_is_generation_in_kWh_and_not_costed() { // A-15: generation is never billed (D-34), so a generation sum has no source costs to add; its default rule is none. var validation = Validate("m4 + m5", meterId: 6); Assert.True(validation.IsValid); Assert.Equal(QuantityKind.Generation, validation.Kind); Assert.Equal("kWh", validation.Unit); Assert.Equal(VirtualCostRule.None, validation.CostRule); } [Fact] public void Source_costs_over_a_nested_difference_are_refused_for_saving_and_not_costed_when_read() { // Review R2 (A-15): m30 + m3 is a pure sum at its own level, but m30 = m1 - m2, so the sources' metered costs // (m1 + m2 + m3) are not the costs of its quantity (m1 - m2 + m3). The quantity stays valid; the rule does not. var catalog = new MeterCatalog( [ .. SeededElectricity(), Virtual(30, "Haus ohne Netz", QuantityKind.Consumption, "kWh", expression: "m1 - m2"), Virtual(31, "Haus und Auto", QuantityKind.Consumption, "kWh", expression: "m1 + m3"), ]); var declared = VirtualValidator.Validate(new VirtualDefinition("m30 + m3", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog); Assert.True(declared.IsValid); Assert.False(declared.IsSavable); Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, declared.CostRuleProblem!.Kind); Assert.Equal([30], declared.CostRuleProblem.MeterIds); Assert.Equal(VirtualCostRule.None, declared.CostRule); var inferred = VirtualValidator.Validate(new VirtualDefinition("m30 + m3"), 40, catalog); Assert.Equal(VirtualCostRule.None, inferred.CostRule); Assert.Null(inferred.CostRuleProblem); // A sum over a nested pure sum is a pure sum all the way down. var nestedSum = VirtualValidator.Validate(new VirtualDefinition("m31 + m2", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts), 40, catalog); Assert.True(nestedSum.IsSavable); Assert.Equal(VirtualCostRule.SourceCosts, nestedSum.CostRule); Assert.Null(VirtualValidator.NestedNonSum(nestedSum.Formula!, 40, catalog)); } [Fact] public void Netz_einsparung_as_haus_minus_netz_is_consumption_and_may_be_priced_as_its_own_quantity() { var byDefault = Validate("m1 - m2"); Assert.True(byDefault.IsValid); Assert.Equal(QuantityKind.Consumption, byDefault.Kind); Assert.Equal(VirtualCostRule.None, byDefault.CostRule); Assert.True(Validate("m1 - m2", costRule: VirtualCostRule.OwnQuantity).IsValid); } [Fact] public void A_syntax_error_is_reported_with_its_position() { var problem = Single(Validate("m1 +")); Assert.Equal(VirtualProblemKind.Syntax, problem.Kind); Assert.Equal(new FormulaError(FormulaErrorKind.UnexpectedEnd, 4), problem.SyntaxError); } [Fact] public void Unknown_meters_are_named() { var problem = Single(Validate("m1 + m99")); Assert.Equal(VirtualProblemKind.UnknownMeter, problem.Kind); Assert.Equal([99], problem.MeterIds); } [Fact] public void A_meter_may_not_refer_to_itself() { var problem = Single(Validate("m6 + m4", meterId: 6)); Assert.Equal(VirtualProblemKind.SelfReference, problem.Kind); Assert.Equal([6], problem.MeterIds); } [Fact] public void A_loop_through_nested_virtual_meters_is_reported_with_its_path() { var catalog = new MeterCatalog( [ .. SeededElectricity(), Virtual(20, "A", QuantityKind.Consumption, "kWh", "m21 + m1"), Virtual(21, "B", QuantityKind.Consumption, "kWh", "m22 - m2"), Virtual(22, "C", QuantityKind.Consumption, "kWh", "m1"), ]); // Saving C as "m20" closes the loop C → A → B → C. var closing = VirtualValidator.Validate(new VirtualDefinition("m20"), 22, catalog); var problem = Assert.Single(closing.Problems); Assert.Equal(VirtualProblemKind.DependencyCycle, problem.Kind); Assert.Equal([22, 20, 21, 22], problem.MeterIds); // A meter that only reads the loop is caught too, with the way into it. var looped = new MeterCatalog([.. catalog.Meters.Where(m => m.MeterId != 22), Virtual(22, "C", QuantityKind.Consumption, "kWh", "m20")]); var reader = VirtualValidator.Validate(new VirtualDefinition("m21 + m3"), 23, looped); Assert.Equal([23, 21, 22, 20, 21], Assert.Single(reader.Problems).MeterIds); } [Fact] public void Adding_different_units_is_refused_even_for_a_declared_net_result() { foreach (var kind in new QuantityKind?[] { null, QuantityKind.Net }) { var problem = Single(Validate("m1 + m7", kind)); Assert.Equal(VirtualProblemKind.UnitMismatch, problem.Kind); Assert.Equal([1, 7], problem.MeterIds); Assert.Equal(["kWh", "m³"], problem.Values); } } [Fact] public void Superscript_and_plain_cubic_metres_are_the_same_unit_and_the_result_is_in_canonical_spelling() { var water = Validate("m10 + m7"); Assert.True(water.IsValid); Assert.Equal("m³", water.Unit); } [Fact] public void Units_are_compared_through_the_shared_unit_table_ignoring_case() { var typed = Validate("m1 - m2", unit: "kwh"); Assert.True(typed.IsValid); Assert.Equal("kWh", typed.Unit); Assert.Equal("kWh", typed.EffectiveDefinition!.ResultUnit); } [Theory] [InlineData(QuantityKind.Runtime)] [InlineData(QuantityKind.Export)] [InlineData(QuantityKind.Cost)] public void A_result_kind_is_consumption_generation_net_or_indicator_and_nothing_else(QuantityKind kind) { var problem = Single(Validate("m9 + m19", kind)); Assert.Equal(VirtualProblemKind.ResultKindUnsupported, problem.Kind); } [Fact] public void A_sum_of_runtime_meters_needs_a_declared_result_kind() { // Two burners' hours are not consumption or generation; without a declaration the reader would take them for // consumption (D-20's fallback) and a runtime result is not one a virtual meter can have. var undeclared = Single(Validate("m9 + m19")); Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind); Assert.Equal(["runtime"], undeclared.Values); var net = Validate("m9 + m19", QuantityKind.Net); Assert.True(net.IsValid); Assert.Equal("h", net.Unit); } [Fact] public void Indicators_are_never_added_up_into_anything_but_an_indicator() { var asNet = Single(Validate("m20 + m21", QuantityKind.Net)); Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, asNet.Kind); Assert.Equal([20, 21], asNet.MeterIds); Assert.Equal(VirtualProblemKind.IndicatorSourceNeedsIndicator, Single(Validate("0.5 * m20", QuantityKind.Net)).Kind); Assert.Equal(VirtualProblemKind.ResultKindRequired, Single(Validate("m20 + m21")).Kind); Assert.Equal(VirtualProblemKind.ResultKindMismatch, Single(Validate("m20", QuantityKind.Consumption)).Kind); var indicator = Validate("m20 + m21", QuantityKind.Indicator); Assert.True(indicator.IsValid); Assert.Equal(VirtualCostRule.None, indicator.CostRule); } [Fact] public void A_formula_over_an_indicator_is_never_costed() { Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, Single(Validate("m20 + m21", QuantityKind.Indicator, costRule: VirtualCostRule.SourceCosts)).Kind); var net = Validate("m20 + m21", QuantityKind.Net, costRule: VirtualCostRule.SourceCosts); Assert.Equal( [VirtualProblemKind.IndicatorSourceNeedsIndicator, VirtualProblemKind.CostRuleNotForIndicator], net.Problems.Select(p => p.Kind)); // Undeclared, the cost rule over an indicator is never the pure-sum default. Assert.Equal(VirtualCostRule.None, Validate("m20 + m21", QuantityKind.Net).CostRule); } [Fact] public void The_effective_definition_writes_out_the_inferred_kind_unit_and_cost_rule() { var summeSolar = Validate("m4 + m5", meterId: 6); Assert.Equal(new VirtualDefinition("m4 + m5", QuantityKind.Generation, "kWh", VirtualCostRule.None), summeSolar.EffectiveDefinition); Assert.Equal(new VirtualDefinition("m7 + m10", QuantityKind.Consumption, "m³", VirtualCostRule.SourceCosts), Validate("m7 + m10").EffectiveDefinition); Assert.Null(Validate("m1 - m4").EffectiveDefinition); } [Fact] public void Mixing_kinds_needs_a_declared_net_result() { var undeclared = Single(Validate("m1 - m4")); Assert.Equal(VirtualProblemKind.ResultKindRequired, undeclared.Kind); Assert.Equal([1, 4], undeclared.MeterIds); Assert.Equal(["consumption", "generation"], undeclared.Values); var asConsumption = Single(Validate("m1 - m4", QuantityKind.Consumption)); Assert.Equal(VirtualProblemKind.KindMismatch, asConsumption.Kind); Assert.Equal([1, 4], asConsumption.MeterIds); var net = Validate("m2 - m8", QuantityKind.Net); Assert.True(net.IsValid); Assert.Equal(QuantityKind.Net, net.Kind); Assert.Equal("kWh", net.Unit); } [Fact] public void A_product_or_quotient_of_meters_must_be_a_declared_indicator_with_its_own_unit() { Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m1 * m2")).Kind); Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("m7 / m1", QuantityKind.Consumption)).Kind); Assert.Equal(VirtualProblemKind.ProductNeedsIndicator, Single(Validate("1 / m1")).Kind); Assert.Equal(VirtualProblemKind.IndicatorNeedsUnit, Single(Validate("m7 / m1", QuantityKind.Indicator)).Kind); var ratio = Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh"); Assert.True(ratio.IsValid); Assert.Equal("m³/kWh", ratio.Unit); Assert.Equal(VirtualCostRule.None, ratio.CostRule); } [Fact] public void Indicators_are_never_costed() { var problem = Single(Validate("m7 / m1", QuantityKind.Indicator, "m³/kWh", VirtualCostRule.OwnQuantity)); Assert.Equal(VirtualProblemKind.CostRuleNotForIndicator, problem.Kind); } [Fact] public void Scaling_by_a_constant_keeps_the_quantity_and_its_unit() { var half = Validate("0.5 * m1 + m2 / 2", costRule: VirtualCostRule.OwnQuantity); Assert.True(half.IsValid); Assert.Equal(QuantityKind.Consumption, half.Kind); Assert.Equal("kWh", half.Unit); } [Fact] public void Source_costs_need_a_pure_sum_and_own_quantity_a_linear_formula() { Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("m1 - m2", costRule: VirtualCostRule.SourceCosts)).Kind); Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, Single(Validate("0.5 * m1", costRule: VirtualCostRule.SourceCosts)).Kind); Assert.Equal(VirtualProblemKind.CostRuleNeedsLinear, Single(Validate("m1 + 5", costRule: VirtualCostRule.OwnQuantity)).Kind); Assert.True(Validate("m1 + 5").IsValid); // allowed, but non-additive and not priceable } [Fact] public void A_declared_kind_or_unit_that_contradicts_the_sources_is_refused() { var kind = Single(Validate("m4 + m5", QuantityKind.Consumption)); Assert.Equal(VirtualProblemKind.ResultKindMismatch, kind.Kind); Assert.Equal(["consumption", "generation"], kind.Values); var unit = Single(Validate("m1 - m2", unit: "MWh")); Assert.Equal(VirtualProblemKind.ResultUnitMismatch, unit.Kind); Assert.Equal(["MWh", "kWh"], unit.Values); Assert.Equal(VirtualProblemKind.ResultKindUnsupported, Single(Validate("m1", QuantityKind.Cost)).Kind); } [Fact] public void A_formula_without_meters_is_not_a_meter() { Assert.Equal(VirtualProblemKind.NoReferences, Single(Validate("5")).Kind); } [Fact] public void Nested_virtual_sources_must_themselves_be_configured_and_parse() { Assert.Equal(VirtualProblemKind.SourceNotConfigured, Single(Validate("m13 + m4")).Kind); Assert.Equal(VirtualProblemKind.SourceInvalid, Single(Validate("m14 + m1")).Kind); } [Fact] public void A_nested_virtual_source_counts_with_its_result_kind_and_unit() { var catalog = new MeterCatalog([.. SeededElectricity(), Virtual(20, "Summe Solar", QuantityKind.Generation, "kWh", "m4 + m5")]); var validation = VirtualValidator.Validate(new VirtualDefinition("m20 - m4"), 21, catalog); Assert.True(validation.IsValid); Assert.Equal(QuantityKind.Generation, validation.Kind); } [Theory] [InlineData(new[] { QuantityKind.Generation, QuantityKind.Generation }, QuantityKind.Generation)] [InlineData(new[] { QuantityKind.Consumption }, QuantityKind.Consumption)] [InlineData(new[] { QuantityKind.Runtime, QuantityKind.Runtime }, null)] [InlineData(new[] { QuantityKind.Export }, null)] [InlineData(new[] { QuantityKind.Net }, null)] [InlineData(new[] { QuantityKind.Consumption, QuantityKind.Generation }, null)] [InlineData(new[] { QuantityKind.Indicator }, null)] [InlineData(new QuantityKind[0], null)] public void The_default_kind_is_consumption_or_generation_when_every_source_shares_it_and_none_otherwise(QuantityKind[] kinds, QuantityKind? expected) { Assert.Equal(expected, VirtualValidator.DefaultKind(kinds)); } [Theory] [InlineData("m4 + m5", null, VirtualCostRule.SourceCosts)] [InlineData("m4 + m5", QuantityKind.Indicator, VirtualCostRule.None)] [InlineData("m1 - m2", null, VirtualCostRule.None)] [InlineData("m1 / m2", QuantityKind.Indicator, VirtualCostRule.None)] public void The_default_cost_rule_is_source_costs_for_pure_sums_and_none_otherwise(string expression, QuantityKind? kind, VirtualCostRule expected) { Assert.Equal(expected, VirtualValidator.DefaultCostRule(Formula.Parse(expression), kind)); } }