using MeterVault.Core.Analysis.Quantities; using MeterVault.Core.Domain; namespace MeterVault.Core.Tests.Analysis; public sealed class MeterRoleRulesTests { private const short Electricity = 1; private const short Water = 2; private static Meter MeterOf( int id, string name, short energyTypeId, MeterMode mode, string? role = null, DateOnly? retiredAt = null) => new() { Id = id, Name = name, EnergyTypeId = energyTypeId, Mode = mode, Unit = "kWh", Meta = MeterMeta.SetRole("{}", role), RetiredAt = retiredAt, }; [Theory] [InlineData("total_load", MeterRole.TotalLoad)] [InlineData("grid_import", MeterRole.GridImport)] [InlineData("grid_export", MeterRole.GridExport)] [InlineData(" GRID_IMPORT ", MeterRole.GridImport)] public void A_stored_token_parses_to_its_role(string token, MeterRole role) { Assert.True(MeterRoleRules.TryParse(token, out var parsed)); Assert.Equal(role, parsed); Assert.Equal(role, MeterRoleRules.Parse(token)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] [InlineData("pv")] [InlineData("grid-import")] public void Anything_else_is_no_role(string? token) { Assert.False(MeterRoleRules.TryParse(token, out _)); Assert.Null(MeterRoleRules.Parse(token)); } [Fact] public void Every_role_round_trips_through_its_stored_token() { foreach (var role in MeterRoleRules.All) { Assert.Equal(role, MeterRoleRules.Parse(MeterRoleRules.Token(role))); } Assert.Equal(MeterRoles.TotalLoad, MeterRoleRules.Token(MeterRole.TotalLoad)); Assert.Equal(MeterRoles.GridImport, MeterRoleRules.Token(MeterRole.GridImport)); Assert.Equal(MeterRoles.GridExport, MeterRoleRules.Token(MeterRole.GridExport)); } [Theory] [InlineData(MeterMode.CumulativeCounter)] [InlineData(MeterMode.DirectDelta)] [InlineData(MeterMode.InstantRate)] public void Meters_that_measure_a_flow_may_hold_every_role(MeterMode mode) { Assert.Equal(MeterRoleRules.All, MeterRoleRules.AllowedFor(mode)); Assert.All(MeterRoleRules.All, role => Assert.True(MeterRoleRules.IsAllowed(role, mode))); } [Theory] [InlineData(MeterMode.GenerationCounter)] [InlineData(MeterMode.RuntimeCounter)] [InlineData(MeterMode.ConsumableBalance)] [InlineData(MeterMode.Virtual)] public void Generation_runtime_tank_and_virtual_meters_hold_no_role(MeterMode mode) { Assert.Empty(MeterRoleRules.AllowedFor(mode)); Assert.All(MeterRoleRules.All, role => Assert.False(MeterRoleRules.IsAllowed(role, mode))); } [Fact] public void The_effective_role_ignores_a_token_the_mode_cannot_hold() { Assert.Equal(MeterRole.GridImport, MeterRoleRules.Effective(MeterOf(1, "Netz", Electricity, MeterMode.CumulativeCounter, "grid_import"))); Assert.Null(MeterRoleRules.Effective(MeterOf(2, "Solar", Electricity, MeterMode.GenerationCounter, "grid_export"))); Assert.Null(MeterRoleRules.Effective(MeterOf(3, "Summe Solar", Electricity, MeterMode.Virtual, "total_load"))); Assert.Null(MeterRoleRules.Effective(MeterOf(4, "Auto", Electricity, MeterMode.CumulativeCounter))); } [Fact] public void Assigning_a_held_role_names_the_meter_it_moves_from() { // The seeded installation: Haus holds total_load, Netz holds grid_import. var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), MeterOf(3, "Zähler Auto", Electricity, MeterMode.CumulativeCounter), }; var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 3); Assert.NotNull(holder); Assert.Equal("Zähler Haus", holder.Name); } [Fact] public void A_free_role_has_no_holder() { var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), }; Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 2)); } [Fact] public void Re_saving_the_holder_does_not_move_the_role_from_itself() { var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad) }; Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.TotalLoad, Electricity, meterId: 1)); } [Fact] public void Roles_are_unique_per_energy_type_not_globally() { var meters = new[] { MeterOf(1, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), MeterOf(2, "Hauswasser", Water, MeterMode.CumulativeCounter), }; Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Water, meterId: 2)); } [Fact] public void Legacy_duplicates_and_invalid_leftovers_all_give_the_role_up() { var meters = new[] { MeterOf(9, "Einspeisung alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport), MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport), MeterOf(7, "Einspeisung Garage", Electricity, MeterMode.DirectDelta, MeterRoles.GridExport), MeterOf(5, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter), MeterOf(6, "Wasser", Water, MeterMode.CumulativeCounter, MeterRoles.GridExport), }; var holders = MeterRoleRules.Holders(meters, MeterRole.GridExport, Electricity, exceptMeterId: 5); // Meters that really play the role first, then the leftover a generation counter cannot hold. Assert.Equal([7, 9, 4], holders.Select(m => m.Id)); } [Fact] public void The_role_is_said_to_move_from_the_meter_that_played_it_not_from_an_invalid_leftover() { // Solar 1 (id 4) still stores a grid_export token it cannot hold; Einspeisung (id 9) is the export meter. var meters = new[] { MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport), MeterOf(9, "Einspeisung", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridExport), MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter), }; var holder = MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12); Assert.NotNull(holder); Assert.Equal("Einspeisung", holder.Name); } [Fact] public void An_invalid_leftover_is_named_only_when_no_meter_played_the_role() { var meters = new[] { MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridExport), MeterOf(12, "Einspeisung neu", Electricity, MeterMode.CumulativeCounter), }; Assert.Equal(4, MeterRoleRules.CurrentHolder(meters, MeterRole.GridExport, Electricity, meterId: 12)!.Id); } [Fact] public void A_retired_meter_keeps_its_role_when_its_replacement_takes_it() { // A-07: the old grid meter measured the grid import of its own service period. var meters = new[] { MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)), MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter), }; var replacement = MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport); Assert.Empty(MeterRoleRules.Holders(meters, MeterRole.GridImport, Electricity, exceptMeterId: 15)); Assert.Null(MeterRoleRules.CurrentHolder(meters, MeterRole.GridImport, Electricity, meterId: 15)); Assert.Empty(MeterRoleRules.Displaced(meters, replacement, MeterRole.GridImport)); } [Fact] public void A_retired_meter_saved_with_a_role_displaces_no_meter_in_service() { var meters = new[] { MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport), MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter), }; var retired = MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30)); Assert.Empty(MeterRoleRules.Displaced(meters, retired, MeterRole.GridImport)); } [Fact] public void A_meter_in_service_saved_with_a_role_displaces_every_other_holder_in_service() { var meters = new[] { MeterOf(1, "Zähler Haus", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), MeterOf(3, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.TotalLoad), MeterOf(8, "Haus alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad, new DateOnly(2020, 12, 31)), MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad), }; var taker = MeterOf(20, "Hauptzähler", Electricity, MeterMode.CumulativeCounter, MeterRoles.TotalLoad); var displaced = MeterRoleRules.Displaced(meters, taker, MeterRole.TotalLoad); Assert.Equal([1, 3], displaced.Select(m => m.Id)); } [Fact] public void A_meter_whose_mode_cannot_hold_the_role_displaces_nobody() { var meters = new[] { MeterOf(2, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport) }; var generation = MeterOf(4, "Solar 1", Electricity, MeterMode.GenerationCounter, MeterRoles.GridImport); Assert.Empty(MeterRoleRules.Displaced(meters, generation, MeterRole.GridImport)); } [Fact] public void A_meter_counts_as_retired_once_its_lifecycle_end_is_recorded() { var retired = RoleCandidate.From(MeterOf(2, "Zähler Netz alt", Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, new DateOnly(2024, 6, 30))); var inService = RoleCandidate.From(MeterOf(15, "Zähler Netz", Electricity, MeterMode.CumulativeCounter, " GRID_IMPORT ")); Assert.Equal(new RoleCandidate(2, Electricity, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true), retired); Assert.False(inService.IsRetired); Assert.Equal(MeterRole.GridImport, inService.StoredRole); Assert.Equal(MeterRole.GridImport, inService.EffectiveRole); } [Fact] public void A_caller_decides_what_retired_means_by_building_the_candidates_itself() { // An analysis input that treats a deactivated meter as retired passes the flag directly. var candidates = new[] { new RoleCandidate(2, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: true), new RoleCandidate(4, 1, MeterMode.GenerationCounter, MeterRoles.GridImport, IsRetired: false), new RoleCandidate(15, 1, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false), new RoleCandidate(16, 2, MeterMode.CumulativeCounter, MeterRoles.GridImport, IsRetired: false), }; var holders = MeterRoleRules.Holders(candidates, MeterRole.GridImport, energyTypeId: 1); Assert.Equal([15, 4], holders.Select(c => c.MeterId)); Assert.Equal(15, MeterRoleRules.CurrentHolder(candidates, MeterRole.GridImport, 1, meterId: 30)!.MeterId); Assert.Null(holders[1].EffectiveRole); } [Fact] public void Malformed_meta_holds_no_role() { var broken = MeterOf(1, "Kaputt", Electricity, MeterMode.CumulativeCounter); broken.Meta = "not json"; Assert.Empty(MeterRoleRules.Holders([broken], MeterRole.TotalLoad, Electricity)); Assert.Null(MeterRoleRules.Effective(broken)); } }