using MeterVault.App.MeterEditing; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Integration.Tests.Analysis; namespace MeterVault.Integration.Tests.Editor; /// /// The meter editor's rules without a browser or a database (brief §5.1, D-23, D-26, D-28, D-31, D-39, A-08, A-15): how /// a calculation is written in Sum, Difference and Formula mode, which sources and cost rules are offered, when a save is /// blocked, what a legacy meter proposes, and the words for a loop, a unit mismatch and a refused totals override. The /// catalog is built from entities (), exactly as a request builds it. /// public sealed class MeterEditorLogicTests { private const int SolarA = 1; private const int SolarB = 2; private const int House = 3; private const int Water = 4; private const int SolarSum = 5; private const int HalfHouse = 6; private const int Draft = 7; private const int ReadsDraft = 8; private const int Garage = 9; private const int Car = 10; private const int Legacy = 11; // ------------------------------------------------------------------------------------------------ writing a calculation [Theory] [InlineData("m1 + m2", CalculationMode.Sum, new[] { 1, 2 })] [InlineData("m2 + m1", CalculationMode.Sum, new[] { 2, 1 })] [InlineData("(m1) + m2", CalculationMode.Sum, new[] { 1, 2 })] [InlineData("m3 - m1 - m2", CalculationMode.Difference, new[] { 3, 1, 2 })] [InlineData("m3 - (m1 - m2)", CalculationMode.Advanced, new[] { 3, 1, 2 })] [InlineData("(m1 - m2) * 0.5", CalculationMode.Advanced, new[] { 1, 2 })] [InlineData("m1 + m1", CalculationMode.Advanced, new[] { 1, 1 })] [InlineData("m1 + 5", CalculationMode.Advanced, new[] { 1 })] public void A_stored_formula_reopens_in_the_mode_its_shape_allows(string expression, CalculationMode mode, int[] meters) { var draft = CalculationDraft.From(new VirtualDefinition(expression, QuantityKind.Consumption, "kWh", VirtualCostRule.None)); Assert.Equal(mode, draft.Mode); switch (mode) { case CalculationMode.Sum: Assert.Equal(meters, draft.SumSources); break; case CalculationMode.Difference: Assert.Equal(meters[0], draft.Minuend); Assert.Equal(meters[1..], draft.Subtrahends); break; default: Assert.Equal(expression, draft.Text); break; } // Whatever the mode, the text it writes is the same calculation. Assert.Equal(Formula.Parse(expression), Formula.Parse(draft.Text)); } [Fact] public void Switching_modes_carries_the_meters_and_the_formula_text() { var draft = new CalculationDraft(); Assert.True(draft.IsIncomplete); Assert.Equal(string.Empty, draft.Text); draft.SetSumSources([SolarA, SolarB]); Assert.Equal("m1 + m2", draft.Text); draft.SetSumSources([SolarB, SolarA, House]); // picking a meter never reorders the ones already there Assert.Equal("m1 + m2 + m3", draft.Text); draft.SwitchTo(CalculationMode.Difference); Assert.Equal("m1 - m2 - m3", draft.Text); draft.SetMinuend(SolarB); // the start is never also subtracted Assert.Equal("m2 - m3", draft.Text); draft.SetSubtrahends([SolarB]); Assert.True(draft.IsIncomplete); // nothing left to subtract draft.SetSubtrahends([House, SolarA]); draft.SwitchTo(CalculationMode.Advanced); Assert.Equal("m2 - m3 - m1", draft.Expression); draft.Expression = "m1 / m3"; draft.InsertReference(SolarB); Assert.Equal("m1 / m3 + m2", draft.Expression); draft.Expression = "m1 * ("; draft.InsertReference(House); Assert.Equal("m1 * ( m3", draft.Expression); // Back from a formula of another shape: the pickers start from the meters it names. draft.Expression = "(m3 - m1) / m2"; draft.SwitchTo(CalculationMode.Sum); Assert.Equal([House, SolarA, SolarB], draft.SumSources); Assert.Equal(CalculationMode.Sum, draft.Mode); } [Fact] public void The_definition_leaves_undeclared_parts_to_inference() { var draft = new CalculationDraft { ResultUnit = " " }; draft.SetSumSources([SolarA, SolarB]); var definition = draft.Definition; Assert.Equal("m1 + m2", definition.Expression); Assert.Null(definition.ResultKind); Assert.Null(definition.ResultUnit); Assert.Null(definition.CostRule); Assert.Equal([SolarA, SolarB], definition.ReferencedMeterIds); } // ------------------------------------------------------------------------------------------------ sources and cost rules [Fact] public void Sum_and_difference_pickers_offer_only_compatible_sources() { var catalog = Catalog(); var options = CalculationSources.For(catalog, MeterDraft.NewMeterId, energyTypeId: 1); // Nothing picked: every meter that can be added up; the draft's own type first. var open = CalculationSources.ForSum(options, []).Select(o => o.MeterId).ToList(); Assert.Contains(SolarA, open); Assert.Contains(Water, open); Assert.True(open.IndexOf(Water) > open.IndexOf(House), "meters of the draft's own energy type come first"); // Once a generation meter in kWh is picked, only generation in kWh fits a sum. var afterSolar = CalculationSources.ForSum(options, [SolarA]).Select(o => o.MeterId).ToHashSet(); Assert.Equal(new HashSet { SolarA, SolarB, SolarSum, Draft, ReadsDraft, Legacy }, afterSolar); // A difference subtracts meters of its start's unit, of any additive kind (import minus export is a net balance). var fromHouse = CalculationSources.ForSubtrahends(options, House, []).Select(o => o.MeterId).ToHashSet(); Assert.Contains(SolarA, fromHouse); Assert.Contains(Garage, fromHouse); Assert.DoesNotContain(House, fromHouse); Assert.DoesNotContain(Water, fromHouse); Assert.True(CalculationSources.MixesKinds(options, [House, SolarA])); // Editing a meter never offers itself, nor a calculation that already reads it (that would close a loop). var forDraft = CalculationSources.For(catalog, Draft, energyTypeId: 1).Select(o => o.MeterId).ToHashSet(); Assert.DoesNotContain(Draft, forDraft); Assert.DoesNotContain(ReadsDraft, forDraft); Assert.Contains(SolarSum, forDraft); // Each option says what it measures, in its normalized unit. var water = options.Single(o => o.MeterId == Water); Assert.Equal("m³", water.Unit); Assert.Equal(QuantityKind.Consumption, water.Kind); Assert.Equal(new DateOnly(2021, 3, 1), options.Single(o => o.MeterId == SolarA).InstalledAt); } [Theory] [InlineData("m3 + m9", null, new[] { VirtualCostRule.None, VirtualCostRule.SourceCosts, VirtualCostRule.OwnQuantity })] [InlineData("m1 + m2", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })] [InlineData("m3 - m9", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })] [InlineData("0.5 * m3", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })] [InlineData("m3 + 5", null, new[] { VirtualCostRule.None })] [InlineData("m3 / m9", QuantityKind.Indicator, new[] { VirtualCostRule.None })] [InlineData("m6 + m9", null, new[] { VirtualCostRule.None, VirtualCostRule.OwnQuantity })] public void Cost_rules_are_offered_only_where_the_calculation_supports_them(string expression, QuantityKind? kind, VirtualCostRule[] offered) { var catalog = Catalog(); var validation = VirtualValidator.Validate(new VirtualDefinition(expression, kind, kind == QuantityKind.Indicator ? "%" : null), MeterDraft.NewMeterId, catalog.Catalog); Assert.Equal(offered, CalculationSources.OfferedCostRules(validation.Formula, validation.Kind, MeterDraft.NewMeterId, catalog.Catalog)); } [Fact] public void A_rule_that_is_not_offered_says_why() { var catalog = Catalog().Catalog; CostRuleUnavailable? Why(VirtualCostRule rule, string expression, QuantityKind? kind = null) { var v = VirtualValidator.Validate(new VirtualDefinition(expression, kind, kind == QuantityKind.Indicator ? "%" : null), MeterDraft.NewMeterId, catalog); return CalculationSources.WhyNot(rule, v.Formula, v.Kind, MeterDraft.NewMeterId, catalog); } Assert.Null(Why(VirtualCostRule.SourceCosts, "m3 + m9")); Assert.Equal(CostRuleBlock.Generation, Why(VirtualCostRule.SourceCosts, "m1 + m2")!.Reason); Assert.Equal(CostRuleBlock.NotPureSum, Why(VirtualCostRule.SourceCosts, "m3 - m9")!.Reason); Assert.Equal(new CostRuleUnavailable(CostRuleBlock.NestedNotPureSum, HalfHouse), Why(VirtualCostRule.SourceCosts, "m6 + m9")); Assert.Equal(CostRuleBlock.NotLinear, Why(VirtualCostRule.OwnQuantity, "m3 + 5")!.Reason); Assert.Equal(CostRuleBlock.Indicator, Why(VirtualCostRule.OwnQuantity, "m3 / m9", QuantityKind.Indicator)!.Reason); Assert.Null(Why(VirtualCostRule.None, "m3 / m9", QuantityKind.Indicator)); } // ------------------------------------------------------------------------------------------------ the calculation section [Fact] public void A_new_calculation_is_saved_only_once_it_is_complete_and_valid() { var model = new VirtualCalculationModel(Catalog(), MeterDraft.NewMeterId, energyTypeId: 1); Assert.False(model.CanSave); Assert.Null(model.EffectiveDefinition); Assert.Empty(model.Problems); // incomplete asks for input, it does not report errors model.Draft.SetSumSources([SolarA, SolarB]); model.Refresh(); // A-08: what a save writes is the effective definition — the inferred kind, the canonical unit, the default rule. Assert.True(model.CanSave); Assert.Equal(QuantityKind.Generation, model.InferredKind); Assert.Equal("kWh", model.InferredUnit); Assert.Equal(VirtualCostRule.None, model.DefaultCostRule); // a generation sum is not costed (A-15) Assert.Equal(new VirtualDefinition("m1 + m2", QuantityKind.Generation, "kWh", VirtualCostRule.None), model.EffectiveDefinition); // A new sum links its sources in the flow view unless told not to (links never change the calculation). Assert.True(model.OffersLinkSync); Assert.True(model.SyncLinks); Assert.Equal([SolarA, SolarB], model.LinksAfterSave); model.SyncLinks = false; Assert.Empty(model.LinksAfterSave); // A unit mismatch blocks the save and says so. model.Draft.SwitchTo(CalculationMode.Advanced); model.Draft.Expression = "m1 + m4"; model.Refresh(); Assert.False(model.CanSave); Assert.Contains(model.Problems, p => p.Kind == VirtualProblemKind.UnitMismatch); } [Fact] public void A_cost_rule_the_formula_stops_supporting_falls_back_to_the_default() { var model = new VirtualCalculationModel(Catalog(), MeterDraft.NewMeterId, energyTypeId: 1); model.Draft.SetSumSources([House, Garage]); model.Refresh(); Assert.Equal(VirtualCostRule.SourceCosts, model.DefaultCostRule); model.Draft.CostRule = VirtualCostRule.OwnQuantity; model.Refresh(); Assert.False(model.CostRuleReset); Assert.Equal(VirtualCostRule.OwnQuantity, model.EffectiveDefinition!.CostRule); // m3 + 5 is not linear: the chosen rule is dropped rather than left as an error the user did not make. model.Draft.SwitchTo(CalculationMode.Advanced); model.Draft.Expression = "m3 + 5"; model.Refresh(); Assert.True(model.CostRuleReset); Assert.Null(model.Draft.CostRule); Assert.Equal([VirtualCostRule.None], model.OfferedCostRules); Assert.Equal(VirtualCostRule.None, model.EffectiveDefinition!.CostRule); } [Fact] public void A_source_cost_rule_over_a_nested_calculation_that_is_not_a_sum_blocks_the_save() { // The formula is a plain sum at its own level, but its source Half house is 0.5 × House (A-15): the validator's // CostRuleProblem must stop a save that stores sourceCosts, even though the definition itself is valid. var catalog = Catalog(); var validation = MeterDraftAnalysis.Validate(catalog, NewVirtual() with { Definition = new VirtualDefinition("m6 + m9", null, null, VirtualCostRule.SourceCosts), }); Assert.True(validation.IsValid); Assert.False(validation.IsSavable); Assert.Equal(VirtualProblemKind.CostRuleNeedsPureSum, validation.CostRuleProblem!.Kind); var text = AnalysisUiTestData.In("en", () => MeterEditorText.Problem(validation.CostRuleProblem, Name(catalog))); Assert.Equal("“Sum of the sources' costs” needs plain sums throughout, but Half house is not a plain sum.", text); } [Fact] public void A_stored_calculation_reopens_with_what_its_sources_imply_as_automatic() { // Solar sum is stored with everything declared (A-08); what merely repeats the sources shows as automatic, so a // change of sources carries the kind along instead of contradicting it. var model = new VirtualCalculationModel(Catalog(), SolarSum, energyTypeId: 1); Assert.Equal(VirtualMeterStatus.Valid, model.Status); Assert.Equal(CalculationMode.Sum, model.Draft.Mode); Assert.Equal([SolarA, SolarB], model.Draft.SumSources); Assert.Null(model.Draft.ResultKind); Assert.Null(model.Draft.ResultUnit); Assert.Null(model.Draft.CostRule); Assert.True(model.CanSave); Assert.False(model.OffersLinkSync); // its links already are its sources } [Fact] public void A_legacy_meter_opens_with_its_implied_sum_as_a_proposal_to_confirm() { var model = new VirtualCalculationModel(Catalog(), Legacy, energyTypeId: 1); Assert.True(model.IsLegacyProposal); Assert.Equal(CalculationMode.Sum, model.Draft.Mode); Assert.Equal([SolarA, SolarB], model.Draft.SumSources.Order()); Assert.True(model.CanSave); Assert.Equal(QuantityKind.Generation, model.EffectiveDefinition!.ResultKind); Assert.False(model.OffersLinkSync); } // ------------------------------------------------------------------------------------------------ findings in words [Fact] public void A_loop_is_named_by_its_path_in_both_languages() { var catalog = Catalog(); var validation = MeterDraftAnalysis.Validate(catalog, new MeterDraft(Draft, "Draft", 1, MeterMode.Virtual, "kWh") { Definition = new VirtualDefinition($"m{ReadsDraft}"), }); var cycle = Assert.Single(validation.Problems, p => p.Kind == VirtualProblemKind.DependencyCycle); Assert.Equal([Draft, ReadsDraft, Draft], cycle.MeterIds); Assert.Equal( "The calculation goes round in a circle: Draft → Reads draft → Draft.", AnalysisUiTestData.In("en", () => MeterEditorText.Problem(cycle, Name(catalog)))); Assert.Equal( "Die Berechnung dreht sich im Kreis: Draft → Reads draft → Draft.", AnalysisUiTestData.In("de", () => MeterEditorText.Problem(cycle, Name(catalog)))); } [Fact] public void A_unit_mismatch_names_both_meters_and_their_units() { var catalog = Catalog(); var validation = MeterDraftAnalysis.Validate(catalog, NewVirtual() with { Definition = new VirtualDefinition("m1 + m4") }); var mismatch = Assert.Single(validation.Problems, p => p.Kind == VirtualProblemKind.UnitMismatch); Assert.Equal( "Solar A (kWh) and Water (m³) cannot be added or subtracted: their units differ.", AnalysisUiTestData.In("en", () => MeterEditorText.Problem(mismatch, Name(catalog)))); Assert.Equal( "Solar A (kWh) und Water (m³) lassen sich nicht addieren oder subtrahieren: Ihre Einheiten unterscheiden sich.", AnalysisUiTestData.In("de", () => MeterEditorText.Problem(mismatch, Name(catalog)))); } [Theory] [InlineData("m1 +", "The formula ends where a meter or a number is expected.")] [InlineData("m1 + n2", "“n2” is not a meter; refer to meters as m and their number, e.g. m12.")] [InlineData("(m1 + m2", "The parenthesis at position 1 is never closed.")] [InlineData("m1 % m2", "“%” is not allowed in a formula (position 4).")] [InlineData("m1 + m3 + m99", "m99 is not a meter.")] [InlineData("m1 + m2 - m3", "Solar A (Generation) and House (Consumption) measure different things; to combine them, set the result to Net.")] [InlineData("m3 * m9", "Multiplying or dividing meters (House, Garage) gives a ratio: set the result to Indicator.")] [InlineData("5", "The formula refers to no meter; a number alone is not a meter.")] public void Validation_problems_read_as_sentences(string expression, string expected) { var catalog = Catalog(); var validation = MeterDraftAnalysis.Validate(catalog, NewVirtual() with { Definition = new VirtualDefinition(expression, KindFor(expression)) }); var texts = AnalysisUiTestData.In("en", () => validation.Problems.Select(p => MeterEditorText.Problem(p, Name(catalog))).ToList()); Assert.Contains(expected, texts); } // ------------------------------------------------------------------------------------------------ totals override (D-23) [Fact] public void Always_on_a_breakdown_is_refused_naming_the_counted_parent() { var catalog = Catalog(); var car = new MeterDraft(Car, "Car", 1, MeterMode.CumulativeCounter, "kWh"); var overlay = MeterDraftAnalysis.Overlay(catalog, car, null); var always = MeterDraftAnalysis.CheckTotals(overlay, car, TotalsOverride.Always); Assert.False(always.IsAllowed); Assert.Equal(new TotalsConflict(TotalsConflictReason.OverlapsCountedMeter, House), always.Conflict); Assert.Equal( "“Always count” would count energy twice: House is already counted and overlaps this meter.", AnalysisUiTestData.In("en", () => MeterEditorText.Conflict(always.Conflict!, Name(catalog)))); Assert.True(MeterDraftAnalysis.CheckTotals(overlay, car, TotalsOverride.Never).IsAllowed); Assert.True(MeterDraftAnalysis.CheckTotals(overlay, car, TotalsOverride.Auto).IsAllowed); // Unlinked from House in the dialog, the same meter is a root of its own and may be counted. var unlinked = car with { Upstream = [] }; Assert.True(MeterDraftAnalysis.CheckTotals(MeterDraftAnalysis.Overlay(catalog, unlinked, null), unlinked, TotalsOverride.Always).IsAllowed); } [Fact] public void A_new_virtual_draft_is_laid_over_the_catalog_with_its_effective_definition() { var catalog = Catalog(); var draft = NewVirtual() with { Definition = new VirtualDefinition("m1 + m2"), Upstream = [SolarA, SolarB] }; var effective = MeterDraftAnalysis.Validate(catalog, draft).EffectiveDefinition!; var overlay = MeterDraftAnalysis.Overlay(catalog, draft, effective); var meter = overlay.Find(MeterDraft.NewMeterId)!; Assert.Equal(VirtualMeterStatus.Valid, meter.VirtualStatus); Assert.Equal(QuantityKind.Generation, meter.Quantity.Kind); Assert.Equal([SolarA, SolarB], overlay.Graph.DirectDependencies(MeterDraft.NewMeterId)); Assert.Equal(2, overlay.Links.Count(l => l.ToMeterId == MeterDraft.NewMeterId)); Assert.Equal(catalog.Meters.Count + 1, overlay.Meters.Count); Assert.Equal(MeterTotalsClass.AnalysisOnly, overlay.Totals.Meters[MeterDraft.NewMeterId].Class); // The stored catalog is untouched. Assert.Null(catalog.Find(MeterDraft.NewMeterId)); } [Fact] public void The_totals_override_is_written_beside_every_other_key() { var meta = MeterDraftAnalysis.WithTotalsOverride("""{"role":"grid_import","expression":"m1"}""", TotalsOverride.Always); Assert.Equal(TotalsOverride.Always, TotalsOverrideTokens.FromMeta(meta)); Assert.Equal("grid_import", MeterMeta.Role(meta)); Assert.Contains("\"expression\":\"m1\"", meta, StringComparison.Ordinal); var auto = MeterDraftAnalysis.WithTotalsOverride(meta, TotalsOverride.Auto); Assert.DoesNotContain("totals", auto, StringComparison.Ordinal); Assert.Equal("grid_import", MeterMeta.Role(auto)); Assert.Equal("""{"totals":"never"}""", MeterDraftAnalysis.WithTotalsOverride("not json", TotalsOverride.Never)); } // ------------------------------------------------------------------------------------------------ helpers /// A declared kind where a case needs one: a product declared as consumption, a mixed sum declared as generation. private static QuantityKind? KindFor(string expression) => expression switch { "m1 + m2 - m3" => QuantityKind.Generation, _ when expression.Contains('*', StringComparison.Ordinal) => QuantityKind.Consumption, _ => null, }; private static MeterDraft NewVirtual() => new(0, "New", 1, MeterMode.Virtual, "kWh"); private static Func Name(AnalysisCatalog catalog) => id => catalog.Find(id)?.Name ?? (id == MeterDraft.NewMeterId ? "New" : CalculationDraft.Token(id)); /// /// Two solar meters and their stored sum, a house with a garage and a car below it, water in another type, a /// calculation that is not a sum (half the house), an edited virtual meter another one reads, and a legacy sum. /// private static AnalysisCatalog Catalog() { static string Def(string expression, QuantityKind kind, string unit, VirtualCostRule rule) => VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, unit, rule)); Meter M(int id, string name, MeterMode mode, string unit = "kWh", short type = 1, string meta = "{}") => new() { Id = id, Name = name, EnergyTypeId = type, Mode = mode, Unit = unit, Meta = meta }; var solarA = M(SolarA, "Solar A", MeterMode.GenerationCounter); solarA.InstalledAt = new DateOnly(2021, 3, 1); Meter[] meters = [ solarA, M(SolarB, "Solar B", MeterMode.GenerationCounter), M(House, "House", MeterMode.CumulativeCounter), M(Water, "Water", MeterMode.CumulativeCounter, "m3", type: 2), M(SolarSum, "Solar sum", MeterMode.Virtual, meta: Def("m1 + m2", QuantityKind.Generation, "kWh", VirtualCostRule.None)), M(HalfHouse, "Half house", MeterMode.Virtual, meta: Def("0.5 * m3", QuantityKind.Consumption, "kWh", VirtualCostRule.None)), M(Draft, "Draft", MeterMode.Virtual, meta: Def("m1 + m2", QuantityKind.Generation, "kWh", VirtualCostRule.None)), M(ReadsDraft, "Reads draft", MeterMode.Virtual, meta: Def("m7", QuantityKind.Generation, "kWh", VirtualCostRule.None)), M(Garage, "Garage", MeterMode.CumulativeCounter), M(Car, "Car", MeterMode.CumulativeCounter), M(Legacy, "Legacy sum", MeterMode.Virtual), ]; MeterLink[] links = [ new() { FromMeterId = SolarA, ToMeterId = SolarSum }, new() { FromMeterId = SolarB, ToMeterId = SolarSum }, new() { FromMeterId = House, ToMeterId = Car }, new() { FromMeterId = SolarA, ToMeterId = Legacy }, new() { FromMeterId = SolarB, ToMeterId = Legacy }, ]; return AnalysisCatalog.Build(meters, [], links, [], AnalysisUiTestData.Berlin); } }