using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Costing; using MeterVault.Core.Domain; using MeterVault.Infrastructure.Analysis; using MeterVault.Infrastructure.Costing; using MeterVault.Infrastructure.Dashboard; using MeterVault.Infrastructure.Options; using MeterVault.Integration.Tests.Costing; using Microsoft.EntityFrameworkCore; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.Specialized; /// /// The Tanks & consumables read model (brief §7.5, D-54) on the shared readers, on the frozen clock of /// (19 September 2026): the last dipstick kept apart from the contents estimated now; a /// period that is over shows its own end's contents, not today's; deliveries only from the period; usage from the /// analysis reader; burner runtime and the burn rate; and a cost that is unknown stays unknown, never 0 (D-38, A-16). /// /// The view reads every consumable meter, so every test starts from — and leaves — an instance without meters. [Collection("Timescale")] public sealed class ConsumableServiceTests(TimescaleFixture fx) : IAsyncLifetime { public async Task InitializeAsync() { await using var db = fx.CreateContext(); await SolarServiceTests.ClearDataAsync(db); } public async Task DisposeAsync() { await using var db = fx.CreateContext(); await SolarServiceTests.ClearDataAsync(db); } [Fact] public async Task Usage_is_up_to_now_and_an_unpriced_cost_is_not_a_zero() { // A dipstick dated after now is no actual: June 200, July 1,800 + 1,000 − 2,600 = 200, not the 25 September draw. // The tank has no tariff: its cost is "not priced", with no value — never a confident 0 €. await using var box = new CostSandbox(fx); var type = await box.TypeAsync("L"); var tank = await TankAsync(box, type, 3000, installedAt: D(2026, 5, 1)); await EventsAsync( Level(tank, Midnight(2026, 6, 1), 2000), Level(tank, Midnight(2026, 7, 1), 1800), Delivery(tank, Midnight(2026, 7, 15), 1000), Level(tank, Midnight(2026, 8, 1), 2600), Level(tank, Midnight(2026, 9, 25), 2500)); await box.RecomputeAsync(tank); var unpriced = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 9, 30))); Assert.Equal(400, unpriced.Usage!.Total.Value!.Value, 6); Assert.Equal("L", unpriced.Usage.Unit); Assert.Equal((2600d, 0d), (unpriced.EstimatedNow!.Volume, unpriced.EstimatedNow.DeliveredSince)); Assert.Equal(Midnight(2026, 8, 1), unpriced.LastDipstick!.Time); Assert.Equal(2600d / 3000, unpriced.FillFraction!.Value, 6); Assert.False(unpriced.PeriodEndsBeforeNow); Assert.Null(unpriced.AtPeriodEnd); var cost = unpriced.Cost!.Total; Assert.Equal(CostStatus.NotPriced, cost.Status); Assert.Null(cost.Cost); Assert.Equal(CostStatus.NotPriced, Assert.Single(cost.MissingPrices).Reason); Assert.Contains(unpriced.Cost.Attention, a => a.Kind == CostAttentionKind.MissingPrice); await box.TypePriceAsync(type, 1.50, D(2026, 1, 1), unit: "EUR/L"); var priced = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 9, 30))); Assert.Equal((400 * 1.50, CostStatus.Priced), (Math.Round(priced.Cost!.Total.Cost!.Value, 6), priced.Cost.Total.Status)); } [Fact] public async Task A_tank_dipped_every_few_months_has_its_cost_and_an_unplaceable_one_is_unknown() { // A-16: dipsticks on 17 January, 3 April, 12 September and 20 December at 1.10 €/L. No month can take a draw, but // the year holds all three whole and one price covers it: 2,100 L cost 2,310 €. The first half cuts the April – // September draw: its cost cannot be placed, and it has no value at all rather than 0 €. await using var box = new CostSandbox(fx); var type = await box.TypeAsync("L"); var tank = await TankAsync(box, type, 7000); await EventsAsync( Level(tank, Midnight(2025, 1, 17), 5000), Level(tank, Midnight(2025, 4, 3), 4200), Level(tank, Midnight(2025, 9, 12), 3600), Level(tank, Midnight(2025, 12, 20), 2900)); await box.RecomputeAsync(tank); await box.TypePriceAsync(type, 1.10, D(2025, 1, 1), unit: "EUR/L"); var year = await TankAsync(tank, Year(2025)); Assert.Equal(2100, year.Usage!.Total.Value!.Value, 6); Assert.Equal((2310d, CostStatus.Priced), (Math.Round(year.Cost!.Total.Cost!.Value, 6), year.Cost.Total.Status)); var half = await TankAsync(tank, Custom(D(2025, 1, 1), D(2025, 6, 30))); Assert.Null(half.Cost!.Total.Cost); Assert.Equal(BucketStatus.Unresolved, half.Cost.Total.Availability); } [Fact] public async Task A_period_that_is_over_shows_its_own_end_not_today_and_only_its_deliveries() { // 10 January 3,000 L; 1 February 2,000 L delivered; 1 March 4,000 L; 1 August 2,500 L; 20 August 1,000 L delivered. await using var box = new CostSandbox(fx); var type = await box.TypeAsync("L"); var tank = await TankAsync(box, type, 7000); await EventsAsync( Level(tank, Midnight(2026, 1, 10), 3000), Delivery(tank, Midnight(2026, 2, 1), 2000), Level(tank, Midnight(2026, 3, 1), 4000), Level(tank, Midnight(2026, 8, 1), 2500), Delivery(tank, Midnight(2026, 8, 20), 1000)); await box.RecomputeAsync(tank); // January – February is over: its end holds the January dipstick plus the February delivery, and only that // delivery is listed. "Now" is still the August dipstick plus the August delivery, apart from it. var winter = await TankAsync(tank, Custom(D(2026, 1, 1), D(2026, 2, 28))); Assert.True(winter.PeriodEndsBeforeNow); Assert.Equal((5000d, 2000d, 1), (winter.AtPeriodEnd!.Volume, winter.AtPeriodEnd.DeliveredSince, winter.AtPeriodEnd.DeliveriesSince)); Assert.Equal(Midnight(2026, 1, 10), winter.AtPeriodEnd.Dipstick.Time); Assert.Equal([Midnight(2026, 2, 1)], winter.Deliveries.Select(d => d.Time)); Assert.Equal(2000, winter.DeliveredInPeriod, 6); Assert.Equal((3500d, Midnight(2026, 8, 1)), (winter.EstimatedNow!.Volume, winter.EstimatedNow.Dipstick.Time)); Assert.Equal(Midnight(2026, 8, 1), winter.LastDipstick!.Time); // A period ending the day before a dipstick does not see it: the end is exclusive (D-03). var february = await TankAsync(tank, Custom(D(2026, 2, 1), D(2026, 2, 28))); Assert.Equal(Midnight(2026, 1, 10), february.AtPeriodEnd!.Dipstick.Time); // Before the first dipstick there is no contents to show — not a 0 L tank. var december = await TankAsync(tank, Custom(D(2025, 12, 1), D(2025, 12, 31))); Assert.True(december.PeriodEndsBeforeNow); Assert.Null(december.AtPeriodEnd); Assert.Empty(december.Deliveries); // A period up to now has no separate end: its deliveries are both, newest first. var year = await TankAsync(tank, Preset(PeriodPreset.YearToDate)); Assert.False(year.PeriodEndsBeforeNow); Assert.Null(year.AtPeriodEnd); Assert.Equal([Midnight(2026, 8, 20), Midnight(2026, 2, 1)], year.Deliveries.Select(d => d.Time)); } [Fact] public async Task The_forecast_is_a_projection_and_hidden_once_the_dipstick_is_older_than_60_days() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync("L"); var fresh = await TankAsync(box, type, 7000); await EventsAsync(Level(fresh, Midnight(2026, 6, 1), 3000), Level(fresh, Midnight(2026, 9, 1), 2100)); var stale = await TankAsync(box, type, 7000); await EventsAsync(Level(stale, Midnight(2026, 4, 1), 3000), Level(stale, Midnight(2026, 7, 1), 2100)); await box.RecomputeAsync(fresh, stale); var analysis = await Service().GetAsync(new ConsumableRequest(Preset(PeriodPreset.Last12Months))); // 900 L over the 92 days from 1 June to 1 September: 2,100 L last 214.7 days from 1 September. var projected = analysis.Tanks.Single(t => t.MeterId == fresh).Forecast; Assert.Equal(TankForecastState.Projected, projected.State); Assert.Equal(92, projected.BasisDays); Assert.Equal(900d / 92, projected.PerDay!.Value, 6); Assert.Equal(D(2027, 4, 3), projected.EmptyOn); Assert.Equal(18, projected.DipstickAgeDays); var hidden = analysis.Tanks.Single(t => t.MeterId == stale).Forecast; Assert.Equal(TankForecastState.DipstickTooOld, hidden.State); Assert.Null(hidden.EmptyOn); Assert.Equal(80, hidden.DipstickAgeDays); } [Fact] public async Task Burner_runtime_and_the_burn_rate_follow_the_period() { // The burner of the tank's energy type ran 100 h in June and 200 h in July; the tank lost 300 L and 400 L. The // empirical rate is 700 L ÷ 300 h. A tank set to a fixed nozzle rate shows that rate instead. await using var box = new CostSandbox(fx); var type = await box.TypeAsync("L"); var tank = await TankAsync(box, type, 7000); await EventsAsync(Level(tank, Midnight(2026, 6, 1), 5000), Level(tank, Midnight(2026, 7, 1), 4700), Level(tank, Midnight(2026, 8, 1), 4300)); await box.RecomputeAsync(tank); var burner = await box.MeterAsync(type, MeterMode.RuntimeCounter, "h", D(2026, 6, 1)); await box.MonthlyReadingsAsync(burner, D(2026, 6, 1), 100, 200); var summer = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 7, 31))); var runtime = Assert.Single(summer.Runtime); Assert.Equal(burner, runtime.MeterId); Assert.Equal(("h", 300d), (summer.RuntimeUnit, summer.RuntimeTotal!.Value!.Value)); Assert.Equal((TankRateSource.Empirical, "L/h"), (summer.Rate!.Source, summer.Rate.Unit)); Assert.Equal((700d / 300, BucketStatus.Available), (summer.Rate.Value.Value!.Value, summer.Rate.Value.Status)); await using (var db = fx.CreateContext()) { await db.Tanks.Where(t => t.MeterId == tank).ExecuteUpdateAsync(s => s .SetProperty(t => t.RateMode, TankRateMode.Fixed) .SetProperty(t => t.FixedRate, 2.1)); } var fixedRate = await TankAsync(tank, Custom(D(2026, 6, 1), D(2026, 7, 31))); Assert.Equal((TankRateSource.Fixed, 2.1), (fixedRate.Rate!.Source, fixedRate.Rate.Value.Value!.Value)); } [Fact] public async Task A_consumable_meter_without_a_tank_is_listed_for_setup() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync("L"); var bare = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", name: $"Tank {Guid.NewGuid():N}"); var analysis = await Service().GetAsync(new ConsumableRequest(Preset(PeriodPreset.Last12Months))); Assert.Empty(analysis.Tanks); Assert.Equal([bare], analysis.Unconfigured.Select(u => u.MeterId)); Assert.Null(analysis.Quantities); Assert.Null(await Service().GetAvailabilityAsync(Now)); } private async Task TankAsync(int tank, ResolvedPeriod period) { var analysis = await Service().GetAsync(new ConsumableRequest(period) { Bucket = BucketSize.Month }); return analysis.Tanks.Single(t => t.MeterId == tank); } private ConsumableService Service() { var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" }); var reader = new AnalysisReader(fx, options); return new ConsumableService(fx, reader, new CostReader(fx, reader, options)); } private async Task TankAsync(CostSandbox box, short type, double capacity, DateOnly? installedAt = null) { var meter = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", installedAt); await using var db = fx.CreateContext(); db.Tanks.Add(new Tank { MeterId = meter, Capacity = capacity, Unit = "L" }); await db.SaveChangesAsync(); return meter; } private async Task EventsAsync(params MeterEvent[] events) { await using var db = fx.CreateContext(); db.MeterEvents.AddRange(events); await db.SaveChangesAsync(); } private static MeterEvent Level(int meterId, DateTimeOffset time, double litres) => new() { MeterId = meterId, EventType = MeterEventType.TankLevel, Time = time.ToUniversalTime(), Amount = litres, Unit = "L" }; private static MeterEvent Delivery(int meterId, DateTimeOffset time, double litres) => new() { MeterId = meterId, EventType = MeterEventType.Delivery, Time = time.ToUniversalTime(), Amount = litres, Unit = "L" }; }