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