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