using MeterVault.Core.Domain;
using MeterVault.Core.Normalization;
using MeterVault.Infrastructure.Dashboard;
namespace MeterVault.Integration.Tests.Specialized;
///
/// The pure rules behind a tank's contents and forecast (D-54, D-09): contents at an instant are the last dipstick up to it
/// plus the deliveries after it; the forecast is a straight line through the dipsticks of the year before the last one,
/// hidden when that one is older than 60 days or the line too short.
///
public sealed class TankLevelsTests
{
private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
private static readonly DateTimeOffset Now = new(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2));
[Fact]
public void Contents_are_the_last_dipstick_plus_the_deliveries_after_it()
{
List events =
[
Level(At(2026, 8, 1), 2000),
Delivery(At(2026, 8, 1), 500), // at the dipstick's instant: already in it, as the normalizer books it
Delivery(At(2026, 8, 10), 1000),
Delivery(At(2026, 9, 1), 300),
];
var now = TankLevels.ContentsAt(events, Now, inclusive: true, calibration: null)!;
Assert.Equal((3300d, 1300d, 2), (now.Volume, now.DeliveredSince, now.DeliveriesSince));
// An exclusive end leaves out what happens at that instant.
var end = TankLevels.ContentsAt(events, At(2026, 9, 1), inclusive: false, calibration: null)!;
Assert.Equal((3000d, 1), (end.Volume, end.DeliveriesSince));
Assert.Null(TankLevels.ContentsAt(events, At(2026, 7, 31), inclusive: true, calibration: null));
}
[Fact]
public void A_centimetre_dipstick_is_calibrated_and_keeps_what_was_read()
{
var level = new MeterEvent { MeterId = 1, EventType = MeterEventType.TankLevel, Time = At(2026, 9, 1), Amount = 85, Unit = "cm" };
var calibrated = TankLevels.Dipstick(level, new CalibrationCurve(46.67));
Assert.Equal((85 * 46.67, 85d, "cm", true), (calibrated.Volume, calibrated.Reading, calibrated.ReadingUnit, calibrated.IsCalibrated));
// Without a calibration the reading is taken as the volume it is — and says it was not calibrated.
var raw = TankLevels.Dipstick(level, calibration: null);
Assert.Equal((85d, false), (raw.Volume, raw.IsCalibrated));
}
[Fact]
public void The_forecast_says_why_it_is_missing()
{
Assert.Equal(TankForecastState.NoDipstick, TankLevels.Forecast([Delivery(At(2026, 9, 1), 1000)], Now, Berlin, null).State);
// One recent dipstick is no line.
var single = TankLevels.Forecast([Level(At(2026, 9, 1), 2000)], Now, Berlin, null);
Assert.Equal((TankForecastState.NotEnoughHistory, 18), (single.State, single.DipstickAgeDays));
// Two dipsticks 20 days apart are too short a line (at least 30 days).
var short_ = TankLevels.Forecast([Level(At(2026, 8, 20), 2200), Level(At(2026, 9, 9), 2000)], Now, Berlin, null);
Assert.Equal(TankForecastState.NotEnoughHistory, short_.State);
// Nothing drawn between the dipsticks (the delivery filled what was used): no end to project.
var none = TankLevels.Forecast([Level(At(2026, 6, 1), 2000), Delivery(At(2026, 7, 1), 500), Level(At(2026, 9, 1), 2500)], Now, Berlin, null);
Assert.Equal((TankForecastState.NoUse, 92), (none.State, none.BasisDays));
// A dipstick of 61 days is too old, whatever came before.
var old = TankLevels.Forecast([Level(At(2026, 5, 1), 3000), Level(At(2026, 7, 20), 2000)], Now, Berlin, null);
Assert.Equal((TankForecastState.DipstickTooOld, 61, (DateOnly?)null), (old.State, old.DipstickAgeDays, old.EmptyOn));
}
[Fact]
public void The_forecast_draws_through_a_year_of_dipsticks_and_adds_the_deliveries_back()
{
// 1 October 2025: 5,000 L; 1 January 2026 a 2,000 L delivery; 1 September 2026: 1,500 L. Dipsticks older than a
// year before the last one are ignored. Draw 5,000 + 2,000 − 1,500 = 5,500 L over 335 days; 1,500 L plus the
// 200 L delivered on 10 September last 1,700 ÷ (5,500 ÷ 335) = 103.5 days from 1 September.
List events =
[
Level(At(2024, 1, 1), 9000),
Level(At(2025, 10, 1), 5000),
Delivery(At(2026, 1, 1), 2000),
Level(At(2026, 9, 1), 1500),
Delivery(At(2026, 9, 10), 200),
];
var forecast = TankLevels.Forecast(events, Now, Berlin, null);
Assert.Equal(TankForecastState.Projected, forecast.State);
Assert.Equal(335, forecast.BasisDays);
Assert.Equal(5500d / 335, forecast.PerDay!.Value, 6);
Assert.Equal(new DateOnly(2026, 12, 13), forecast.EmptyOn);
}
private static DateTimeOffset At(int year, int month, int day) =>
GapAttribution.LocalMidnight(new DateOnly(year, month, day), Berlin);
private static MeterEvent Level(DateTimeOffset time, double litres) =>
new() { MeterId = 1, EventType = MeterEventType.TankLevel, Time = time, Amount = litres, Unit = "L" };
private static MeterEvent Delivery(DateTimeOffset time, double litres) =>
new() { MeterId = 1, EventType = MeterEventType.Delivery, Time = time, Amount = litres, Unit = "L" };
}