using System.Text.Json; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Core.Normalization; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Normalization; using MeterVault.Infrastructure.Options; using MeterVault.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; namespace MeterVault.Integration.Tests.Analysis; /// /// Managing virtual meters outside an analysis read: the startup conversion of expression-less ("legacy") virtual /// meters to explicit definitions (D-28), and the dependents a meter's delete dialog must name (D-33). Every test /// creates its own energy type and meters and removes them again; assertions only look at those meters, because /// the upgrade runs over the whole instance. /// [Collection("Timescale")] public sealed class VirtualManagementTests(TimescaleFixture fx) : IAsyncLifetime { private const string Zone = "Europe/Berlin"; private readonly List _meters = []; private readonly List _types = []; public Task InitializeAsync() => Task.CompletedTask; public async Task DisposeAsync() { await using var db = fx.CreateContext(); var ids = _meters.ToArray(); await db.Consumption.Where(c => ids.Contains(c.MeterId)).ExecuteDeleteAsync(); await db.Readings.Where(r => ids.Contains(r.MeterId)).ExecuteDeleteAsync(); await db.Meters.Where(m => ids.Contains(m.Id)).ExecuteDeleteAsync(); var types = _types.ToArray(); await db.EnergyTypes.Where(t => types.Contains(t.Id)).ExecuteDeleteAsync(); } [Fact] public async Task Legacy_meters_get_the_sum_their_links_imply_in_dependency_order() { var type = await TypeAsync(); var solar1 = await MeterAsync(type, MeterMode.GenerationCounter); var solar2 = await MeterAsync(type, MeterMode.GenerationCounter); var solar3 = await MeterAsync(type, MeterMode.GenerationCounter); var house = await MeterAsync(type, MeterMode.CumulativeCounter); // B sums A (itself legacy) and Solar 3, so A has to be written first. A keeps a key of its own. var b = await MeterAsync(type, MeterMode.Virtual); var a = await MeterAsync(type, MeterMode.Virtual, meta: """{"note":"kept"}"""); await LinksAsync((solar1, a), (solar2, a), (a, b), (solar3, b)); // Not convertible: consumption plus generation, and nothing linked at all. var mixed = await MeterAsync(type, MeterMode.Virtual); await LinksAsync((house, mixed), (solar1, mixed)); var lonely = await MeterAsync(type, MeterMode.Virtual); // Never touched: an explicit expression (the authority, whatever links say) and a malformed blob. var definedMeta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{solar1}", QuantityKind.Generation, "kWh", VirtualCostRule.SourceCosts)); var defined = await MeterAsync(type, MeterMode.Virtual, meta: definedMeta); var malformed = await MeterAsync(type, MeterMode.Virtual, meta: """{"expression":5}"""); await LinksAsync((solar2, defined), (solar1, malformed), (solar2, malformed)); var before = await MetasAsync(); var result = await UpgradeAsync(); // A before B, as the derivation saw them; nothing else of this test converted. Assert.Equal([a, b], result.Converted.Where(_meters.Contains)); Assert.DoesNotContain(result.Failed, _meters.Contains); var unresolved = result.NeedsConfiguration.Where(u => _meters.Contains(u.MeterId)).ToDictionary(u => u.MeterId, u => u.Outcome); Assert.Equal(LegacyDerivationOutcome.MixedKinds, unresolved[mixed]); Assert.Equal(LegacyDerivationOutcome.NoSources, unresolved[lonely]); Assert.Equal(2, unresolved.Count); var after = await MetasAsync(); var aRead = VirtualDefinitionJson.Read(after[a]); Assert.Equal(VirtualDefinitionReadStatus.Present, aRead.Status); Assert.Equal($"m{solar1} + m{solar2}", aRead.Definition!.Expression); Assert.Equal(QuantityKind.Generation, aRead.Definition.ResultKind); Assert.Equal("kWh", aRead.Definition.ResultUnit); Assert.Equal(VirtualCostRule.None, aRead.Definition.CostRule); // a generation sum is not costed (A-15) Assert.False(aRead.ReferencedIdsStale); using (var doc = JsonDocument.Parse(after[a])) { Assert.Equal("kept", doc.RootElement.GetProperty("note").GetString()); } var bRead = VirtualDefinitionJson.Read(after[b]); Assert.Equal(new[] { a, solar3 }.Order(), bRead.Definition!.ReferencedMeterIds); Assert.True(bRead.Definition.Formula!.IsPureSum); Assert.Equal(QuantityKind.Generation, bRead.Definition.ResultKind); // What cannot be converted, or needs no conversion, is left exactly as it was. foreach (var id in new[] { mixed, lonely, defined, malformed }) { Assert.Equal(before[id], after[id]); } // The converted meters record the kind their definition now declares. await using (var db = fx.CreateContext()) { var state = await db.MeterRollupStates.AsNoTracking().SingleAsync(s => s.MeterId == a); Assert.Equal(QuantityKind.Generation, state.Kind); Assert.Equal("kWh", state.NormalizedUnit); } // The reader now evaluates the stored definitions; the links are topology only. var catalog = await Reader().LoadCatalogAsync(); Assert.Equal(VirtualMeterStatus.Valid, catalog.Find(a)!.VirtualStatus); Assert.Equal(VirtualMeterStatus.Valid, catalog.Find(b)!.VirtualStatus); Assert.Equal(VirtualMeterStatus.NeedsConfiguration, catalog.Find(mixed)!.VirtualStatus); Assert.Equal(VirtualMeterStatus.Malformed, catalog.Find(malformed)!.VirtualStatus); // Idempotent: a second run converts nothing and changes nothing. var rerun = await UpgradeAsync(); Assert.DoesNotContain(rerun.Converted, _meters.Contains); Assert.Equal(after, await MetasAsync()); } [Fact] public async Task A_converted_calculation_no_longer_follows_its_links() { var type = await TypeAsync(); var solar1 = await MeterAsync(type, MeterMode.GenerationCounter); var solar2 = await MeterAsync(type, MeterMode.GenerationCounter); var sum = await MeterAsync(type, MeterMode.Virtual); await LinksAsync((solar1, sum), (solar2, sum)); await UpgradeAsync(); // Brief ยง5.2: after the conversion, editing the flow links must not secretly change the calculation. await using (var db = fx.CreateContext()) { await db.MeterLinks.Where(l => l.FromMeterId == solar2 && l.ToMeterId == sum).ExecuteDeleteAsync(); } var catalog = await Reader().LoadCatalogAsync(); Assert.Equal(VirtualMeterStatus.Valid, catalog.Find(sum)!.VirtualStatus); Assert.Equal(new[] { solar1, solar2 }.Order(), catalog.Find(sum)!.Formula!.MeterIds); } [Fact] public async Task Dependents_name_every_virtual_meter_that_reads_a_meter() { var type = await TypeAsync(); var p = await MeterAsync(type, MeterMode.GenerationCounter, name: "P"); var q = await MeterAsync(type, MeterMode.GenerationCounter, name: "Q"); var direct = await VirtualAsync(type, "Direct", $"m{p} + m{q}", QuantityKind.Generation, VirtualCostRule.SourceCosts); var nested = await VirtualAsync(type, "Nested", $"m{direct} + m{q}", QuantityKind.Generation, VirtualCostRule.SourceCosts); var unrelated = await VirtualAsync(type, "Only Q", $"m{q}", QuantityKind.Generation, VirtualCostRule.SourceCosts); // An invalid formula still breaks when p goes, and a legacy sum reads p through its link. var invalid = await VirtualAsync(type, "Invalid", $"m{p} * m{q}", QuantityKind.Generation, VirtualCostRule.None); var legacy = await MeterAsync(type, MeterMode.Virtual, name: "Legacy"); await LinksAsync((p, legacy)); var service = new VirtualMeterService(Reader()); var ofP = (await service.GetDependentsAsync(p)).ToDictionary(d => d.MeterId); Assert.Equal(new[] { direct, nested, invalid, legacy }.Order(), ofP.Keys.Order()); Assert.DoesNotContain(unrelated, ofP.Keys); Assert.True(ofP[direct].IsDirect); Assert.Equal([direct, p], ofP[direct].Path); Assert.False(ofP[nested].IsDirect); Assert.Equal([nested, direct, p], ofP[nested].Path); Assert.Equal(VirtualMeterStatus.Invalid, ofP[invalid].Status); Assert.Equal(VirtualMeterStatus.Legacy, ofP[legacy].Status); Assert.Equal("Direct", ofP[direct].Name); var ofQ = (await service.GetDependentsAsync(q)).Select(d => d.MeterId).Order(); Assert.Equal(new[] { direct, nested, unrelated, invalid }.Order(), ofQ); // Nothing reads the outermost sum. Assert.Empty(await service.GetDependentsAsync(nested)); } // ------------------------------------------------------------------------------------------------ helpers private AnalysisReader Reader() => new(fx, Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = Zone })); private async Task UpgradeAsync() { await using var db = fx.CreateContext(); var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = Zone }); var normalization = new NormalizationService(db, NormalizationEngine.CreateDefault(), options); return await new VirtualDefinitionUpgrade(db, normalization, NullLogger.Instance).RunAsync(); } private async Task> MetasAsync() { await using var db = fx.CreateContext(); var ids = _meters.ToArray(); return await db.Meters.AsNoTracking().Where(m => ids.Contains(m.Id)).ToDictionaryAsync(m => m.Id, m => m.Meta); } private async Task TypeAsync() { await using var db = fx.CreateContext(); var type = new EnergyType { Key = $"virtual-{Guid.NewGuid():N}", DisplayName = "Virtual test", BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter }; db.EnergyTypes.Add(type); await db.SaveChangesAsync(); _types.Add(type.Id); return type.Id; } private async Task MeterAsync(short type, MeterMode mode, string meta = "{}", string? name = null) { await using var db = fx.CreateContext(); var meter = new Meter { Name = name ?? $"virtual-{Guid.NewGuid():N}", EnergyTypeId = type, Mode = mode, Unit = "kWh", Meta = meta }; db.Meters.Add(meter); await db.SaveChangesAsync(); _meters.Add(meter.Id); return meter.Id; } private Task VirtualAsync(short type, string name, string expression, QuantityKind kind, VirtualCostRule rule) => MeterAsync(type, MeterMode.Virtual, VirtualDefinitionJson.Write("{}", new VirtualDefinition(expression, kind, "kWh", rule)), name); private async Task LinksAsync(params (int From, int To)[] links) { await using var db = fx.CreateContext(); db.MeterLinks.AddRange(links.Select(l => new MeterLink { FromMeterId = l.From, ToMeterId = l.To })); await db.SaveChangesAsync(); } }