using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Dashboard; using Microsoft.EntityFrameworkCore; namespace MeterVault.Integration.Tests; /// /// "Manage connections" against the database (D-30, D-25, D-28): a connection is stored only when the rules allow it — /// never a loop, a duplicate, a link across types or into a meter itself — the check runs again inside the saving /// transaction, and editing links never touches a virtual meter's stored calculation. A legacy virtual meter, still /// calculated from its links, keeps them. /// [Collection("Timescale")] public sealed class MeterLinkServiceTests(TimescaleFixture fx) { [Fact] public async Task Connections_are_added_and_removed_but_never_close_a_loop() { await using var data = new TopologyData(fx); var type = await data.TypeAsync(); var house = await data.MeterAsync(type, "House", null); var car = await data.MeterAsync(type, "Car", null); var wallbox = await data.MeterAsync(type, "Wallbox", null); var service = new MeterLinkService(fx); Assert.True((await service.AddAsync(house, car)).IsAllowed); Assert.True((await service.AddAsync(car, wallbox)).IsAllowed); var loop = await service.AddAsync(wallbox, house); Assert.Equal(MeterLinkRefusal.WouldCreateCycle, loop.Refusal); Assert.Equal([house, car, wallbox], loop.Path); Assert.Equal(MeterLinkRefusal.AlreadyLinked, (await service.AddAsync(house, car)).Refusal); Assert.Equal(MeterLinkRefusal.SameMeter, (await service.AddAsync(car, car)).Refusal); Assert.Equal(MeterLinkRefusal.UnknownMeter, (await service.AddAsync(car, int.MaxValue)).Refusal); var otherType = await data.TypeAsync("m3"); var water = await data.MeterAsync(otherType, "Water", null); Assert.Equal(MeterLinkRefusal.OtherEnergyType, (await service.AddAsync(house, water)).Refusal); await using (var db = fx.CreateContext()) { var stored = await db.MeterLinks.Where(l => l.FromMeterId == house || l.FromMeterId == car || l.FromMeterId == wallbox) .Select(l => new { l.FromMeterId, l.ToMeterId }).ToListAsync(); Assert.Equal(2, stored.Count); Assert.DoesNotContain(stored, l => l.FromMeterId == wallbox); } // The topology names both ends, and a removed link can be removed only once. var topology = await service.GetAsync(type); Assert.Equal(3, topology.Meters.Count); var first = Assert.Single(topology.Links, l => l.FromMeterId == house); Assert.Equal(car, first.ToMeterId); Assert.True((await service.RemoveAsync(first.LinkId)).IsAllowed); Assert.Equal(MeterLinkRefusal.NotFound, (await service.RemoveAsync(first.LinkId)).Refusal); // With the loop's first edge gone, Wallbox → House is a plain new edge. Assert.True((await service.AddAsync(wallbox, house)).IsAllowed); } [Fact] public async Task Editing_links_never_changes_a_stored_calculation_and_keeps_a_legacy_meters_links() { await using var data = new TopologyData(fx); var type = await data.TypeAsync(); var solar1 = await data.MeterAsync(type, "Solar 1", null, MeterMode.GenerationCounter); var solar2 = await data.MeterAsync(type, "Solar 2", null, MeterMode.GenerationCounter); var definition = new VirtualDefinition($"m{solar1} + m{solar2}", QuantityKind.Generation, "kWh", VirtualCostRule.None); var sum = await data.MeterAsync(type, "Sum", null, MeterMode.Virtual, VirtualDefinitionJson.Write("{}", definition)); var legacy = await data.MeterAsync(type, "Legacy", null, MeterMode.Virtual); await data.LinkAsync(solar1, legacy); var service = new MeterLinkService(fx); string metaBefore; await using (var db = fx.CreateContext()) { metaBefore = await db.Meters.Where(m => m.Id == sum).Select(m => m.Meta).SingleAsync(); } // A stored calculation: the link is topology only, and saving it leaves the formula exactly as it was (D-25). Assert.True((await service.AddAsync(solar1, sum)).IsAllowed); var topology = await service.GetAsync(type); var mirror = Assert.Single(topology.Links, l => l.ToMeterId == sum); Assert.True(topology.MirrorsCalculation(mirror)); Assert.True((await service.RemoveAsync(mirror.LinkId)).IsAllowed); await using (var db = fx.CreateContext()) { Assert.Equal(metaBefore, await db.Meters.Where(m => m.Id == sum).Select(m => m.Meta).SingleAsync()); } // A legacy meter is still calculated from its links (D-28): neither a new one nor a removal is allowed here. Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, (await service.AddAsync(solar2, legacy)).Refusal); var kept = Assert.Single((await service.GetAsync(type)).Links, l => l.ToMeterId == legacy); Assert.Equal(MeterLinkRefusal.CalculatedFromLinks, (await service.RemoveAsync(kept.LinkId)).Refusal); await using (var db = fx.CreateContext()) { Assert.Equal(1, await db.MeterLinks.CountAsync(l => l.ToMeterId == legacy)); Assert.Equal("{}", await db.Meters.Where(m => m.Id == legacy).Select(m => m.Meta).SingleAsync()); } } }