using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Analysis.Totals; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Integration.Tests.Costing; using Microsoft.EntityFrameworkCore; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.Analysis; /// /// Saving a role (review virtual F1, D-21, A-07): it moves from the holder in service instead of leaving two, a retired /// holder keeps it for its history, and a meter whose mode may not hold a role does not keep one. /// [Collection("Timescale")] public sealed class MeterRoleAssignmentTests(TimescaleFixture fx) : IAsyncLifetime { private CostSandbox _box = null!; public Task InitializeAsync() { _box = new CostSandbox(fx); return Task.CompletedTask; } public async Task DisposeAsync() => await _box.DisposeAsync(); [Fact] public async Task Saving_grid_import_on_a_new_meter_moves_it_from_the_old_one_and_the_bill_follows() { // The grid meter was replaced by a new record, and the old one never retired. Before, the lower id kept the role // and the new meter dropped out of every measure and the bill. var type = await _box.TypeAsync(); var old = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await _box.MonthlyReadingsAsync(old, D(2026, 1, 1), 100); var replacement = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await _box.MonthlyReadingsAsync(replacement, D(2026, 2, 1), 80); await _box.TypePriceAsync(type, 0.10, D(2026, 1, 1)); await using (var db = fx.CreateContext()) { var holder = MeterRoleAssignment.CurrentHolder( await db.Meters.Where(m => m.EnergyTypeId == type).ToListAsync(), MeterRole.GridImport, type, replacement, takerRetired: false); Assert.Equal(old, holder!.Id); await using var tx = await db.Database.BeginTransactionAsync(); var moved = await MeterRoleAssignment.ApplyAsync(db, replacement, Normalization(db)); await tx.CommitAsync(); Assert.Equal([old], moved.Select(m => m.Id)); } await using (var db = fx.CreateContext()) { Assert.Null(MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == old)).Meta)); Assert.Equal(MeterRoles.GridImport, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == replacement)).Meta)); } var bill = await _box.Reader().ReadAsync(new CostAnalysisRequest(CostScope.ForEnergyType(type), Month(2026, 2)) { Bucket = BucketSize.Month }); Assert.Equal(BillingBasis.GridImport, Assert.Single(bill.EnergyTypes).Basis); Assert.Contains(bill.Lines, l => l.MeterId == replacement); CostAssert.Priced(8, bill.Total); } [Fact] public async Task A_retired_holder_keeps_its_role_and_so_does_its_successor() { var type = await _box.TypeAsync(); var retired = await _box.MeterAsync( type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport), retiredAt: D(2026, 1, 31)); var successor = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 2, 1), MeterMeta.WithRole("{}", MeterRoles.GridImport)); await using var db = fx.CreateContext(); Assert.Null(MeterRoleAssignment.CurrentHolder(await db.Meters.Where(m => m.EnergyTypeId == type).ToListAsync(), MeterRole.GridImport, type, successor, false)); Assert.Empty(await MeterRoleAssignment.ApplyAsync(db, successor, Normalization(db))); Assert.Empty(await MeterRoleAssignment.ApplyAsync(db, retired, Normalization(db))); db.ChangeTracker.Clear(); Assert.Equal(MeterRoles.GridImport, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == retired)).Meta)); Assert.Equal(MeterRoles.GridImport, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == successor)).Meta)); } [Theory] [InlineData(MeterMode.Virtual)] [InlineData(MeterMode.GenerationCounter)] [InlineData(MeterMode.ConsumableBalance)] [InlineData(MeterMode.RuntimeCounter)] public async Task A_meter_whose_mode_may_not_hold_a_role_does_not_keep_one(MeterMode mode) { var type = await _box.TypeAsync(); var holder = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.TotalLoad)); var meter = await _box.MeterAsync(type, mode, "kWh", D(2026, 1, 1), MeterMeta.WithRole("""{"note":"kept"}""", MeterRoles.TotalLoad)); Assert.Null(MeterRoleAssignment.AllowedToken(mode, MeterRoles.TotalLoad)); await using var db = fx.CreateContext(); Assert.Empty(await MeterRoleAssignment.ApplyAsync(db, meter, Normalization(db))); db.ChangeTracker.Clear(); var stored = await db.Meters.SingleAsync(m => m.Id == meter); Assert.Null(MeterMeta.Role(stored.Meta)); Assert.Equal("kept", MeterMeta.ReadString(stored.Meta, "note")); Assert.Equal(MeterRoles.TotalLoad, MeterMeta.Role((await db.Meters.SingleAsync(m => m.Id == holder)).Meta)); } [Fact] public async Task A_displaced_export_meter_is_rebuilt_as_consumption() { // The rollup state records the kind a role gives a meter (D-20): an export meter that gives grid_export up measures // consumption from then on, so it is recomputed with the move. var type = await _box.TypeAsync(); var export = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport)); await _box.MonthlyReadingsAsync(export, D(2026, 1, 1), 50); var taker = await _box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", D(2026, 1, 1), MeterMeta.WithRole("{}", MeterRoles.GridExport)); await using var db = fx.CreateContext(); Assert.Equal(QuantityKind.Export, (await db.MeterRollupStates.SingleAsync(s => s.MeterId == export)).Kind); await using (var tx = await db.Database.BeginTransactionAsync()) { await MeterRoleAssignment.ApplyAsync(db, taker, Normalization(db)); await tx.CommitAsync(); } db.ChangeTracker.Clear(); Assert.Equal(QuantityKind.Consumption, (await db.MeterRollupStates.SingleAsync(s => s.MeterId == export)).Kind); } }