using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.Integration.Tests;
///
/// The per-energy-type flow graph (Sankey): a single-parent chain attributes the child's full
/// consumption to its parent and shows the remainder as "Other"; a two-parent merge splits the
/// child's consumption proportionally to the parents' own consumption.
///
[Collection("Timescale")]
public sealed class FlowServiceTests(TimescaleFixture fx)
{
[Fact]
public async Task Single_parent_chain_makes_other_remainder()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_a");
var main = await AddMeterAsync(db, "Main", type);
var car = await AddMeterAsync(db, "Car", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = main.Id, ToMeterId = car.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, main.Id, 100);
await AddConsumptionAsync(db, car.Id, 30);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
Assert.Equal(100, graph.Total, 1);
var link = Assert.Single(graph.Links, l => l.To == $"m{car.Id}");
Assert.Equal(30, link.Value, 1); // full child consumption flows from its single parent
var other = Assert.Single(graph.Nodes, n => n.IsOther);
Assert.Equal(70, other.Value, 1); // 100 − 30
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Two_parents_split_child_proportionally()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_b");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar draw", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, grid.Id, 75);
await AddConsumptionAsync(db, solar.Id, 25);
await AddConsumptionAsync(db, house.Id, 40);
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// House (40) splits 75:25 → 30 from grid, 10 from solar.
Assert.Equal(30, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(10, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
}
finally
{
await ClearAsync(db);
}
}
[Fact]
public async Task Generation_meter_counts_as_source()
{
await using var db = fx.CreateContext();
try
{
var type = await SeedTypeAsync(db, "flow_elec_c");
var grid = await AddMeterAsync(db, "Grid", type);
var solar = await AddMeterAsync(db, "Solar", type);
var house = await AddMeterAsync(db, "House", type);
db.MeterLinks.Add(new MeterLink { FromMeterId = grid.Id, ToMeterId = house.Id });
db.MeterLinks.Add(new MeterLink { FromMeterId = solar.Id, ToMeterId = house.Id });
await db.SaveChangesAsync();
await AddConsumptionAsync(db, grid.Id, 75); // grid import
await AddConsumptionAsync(db, solar.Id, 30, ConsumptionKind.Generation); // solar generation
await AddConsumptionAsync(db, house.Id, 40); // house load
var graph = await new FlowService(fx).GetFlowAsync(type, new DateOnly(2024, 1, 1), new DateOnly(2024, 12, 31));
// Solar's generation makes it a real source: House (40) splits 75:30 across grid+solar.
Assert.Equal(40.0 * 75 / 105, graph.Links.Single(l => l.From == $"m{grid.Id}" && l.To == $"m{house.Id}").Value, 1);
Assert.Equal(40.0 * 30 / 105, graph.Links.Single(l => l.From == $"m{solar.Id}" && l.To == $"m{house.Id}").Value, 1);
// Remainder across grid+solar = (75+30) − 40 = 65 (export + battery/inverter losses).
Assert.Equal(65, graph.Nodes.Where(n => n.IsOther).Sum(n => n.Value), 1);
}
finally
{
await ClearAsync(db);
}
}
private static async Task SeedTypeAsync(MeterVaultDbContext db, string key)
{
var type = new EnergyType { Key = key, DisplayName = key, BaseUnit = "kWh", DefaultMode = MeterMode.CumulativeCounter };
db.EnergyTypes.Add(type);
await db.SaveChangesAsync();
return type.Id;
}
private static async Task AddMeterAsync(MeterVaultDbContext db, string name, short type)
{
var meter = new Meter { Name = name, EnergyTypeId = type, Mode = MeterMode.DirectDelta, Unit = "kWh" };
db.Meters.Add(meter);
await db.SaveChangesAsync();
return meter;
}
private static async Task AddConsumptionAsync(MeterVaultDbContext db, int meterId, double amount, ConsumptionKind kind = ConsumptionKind.Consumption)
{
db.Consumption.Add(new Consumption
{
MeterId = meterId,
Time = new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero),
Amount = amount,
Kind = kind,
Quality = ReadingQuality.Manual,
});
await db.SaveChangesAsync();
}
private static async Task ClearAsync(MeterVaultDbContext db)
{
await db.MeterLinks.ExecuteDeleteAsync();
await db.Consumption.ExecuteDeleteAsync();
await db.Meters.ExecuteDeleteAsync();
await db.EnergyTypes.Where(t => t.Key.StartsWith("flow_elec_")).ExecuteDeleteAsync();
}
}