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" };
}