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