using MeterVault.Core.Analysis; namespace MeterVault.Core.Tests.Analysis; /// D-18: the freshness mark is the last reading or event, and only a live source can be stale. public sealed class FreshnessRulesTests { private static readonly DateTimeOffset Now = new(2026, 9, 19, 12, 0, 0, TimeSpan.Zero); [Fact] public void Without_any_reading_or_event_there_is_no_data() { var freshness = FreshnessRules.Evaluate(new FreshnessInput(null, null, [], HasLiveSource: true, null), Now); Assert.Equal(FreshnessState.NoData, freshness.State); Assert.Null(freshness.LastActivity); } [Fact] public void An_import_only_meter_is_historical_never_stale() { var years = Now.AddYears(-3); var freshness = FreshnessRules.Evaluate(new FreshnessInput(years, null, [years], HasLiveSource: false, null), Now); Assert.Equal(FreshnessState.Historical, freshness.State); Assert.Equal(years, freshness.LastActivity); } [Fact] public void A_live_source_is_stale_after_three_of_its_own_intervals() { // Readings every 10 minutes; the last one 40 minutes ago is more than 3 × 10 minutes. var times = Enumerable.Range(0, 21).Select(i => Now.AddMinutes(-40 - (10 * i))).ToList(); var stale = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), Now); var fresh = FreshnessRules.Evaluate(new FreshnessInput(times[0], null, times, HasLiveSource: true, null), times[0].AddMinutes(25)); Assert.Equal(FreshnessState.Stale, stale.State); Assert.Equal(TimeSpan.FromMinutes(30), stale.StaleAfter); Assert.Equal(FreshnessState.Live, fresh.State); } [Fact] public void The_poll_interval_keeps_a_rarely_polled_source_live_between_polls() { // Two readings a minute apart, but the source is polled hourly: 3 × 60 min wins over 3 × 1 min. var last = Now.AddMinutes(-90); var times = new[] { last, last.AddMinutes(-1) }; var freshness = FreshnessRules.Evaluate(new FreshnessInput(last, null, times, HasLiveSource: true, TimeSpan.FromHours(1)), Now); Assert.Equal(FreshnessState.Live, freshness.State); Assert.Equal(TimeSpan.FromHours(3), freshness.StaleAfter); } [Fact] public void An_event_after_the_last_reading_is_the_mark() { var reading = Now.AddDays(-10); var delivery = Now.AddHours(-1); var freshness = FreshnessRules.Evaluate(new FreshnessInput(reading, delivery, [reading], HasLiveSource: false, null), Now); Assert.Equal(delivery, freshness.LastActivity); } [Fact] public void A_live_source_with_an_unknown_rhythm_is_not_called_stale() { var once = Now.AddDays(-30); var freshness = FreshnessRules.Evaluate(new FreshnessInput(once, null, [once], HasLiveSource: true, null), Now); Assert.Equal(FreshnessState.Live, freshness.State); Assert.Null(freshness.StaleAfter); } [Fact] public void The_median_uses_the_latest_twenty_intervals() { // 30 readings: the oldest ten are a day apart, the latest 21 an hour apart. var times = Enumerable.Range(0, 21).Select(i => Now.AddHours(-i)) .Concat(Enumerable.Range(1, 9).Select(i => Now.AddHours(-20).AddDays(-i))) .ToList(); Assert.Equal(TimeSpan.FromHours(1), FreshnessRules.MedianInterval(times)); Assert.Null(FreshnessRules.MedianInterval([Now])); } [Fact] public void Combined_freshness_is_as_current_as_its_least_current_source() { var combined = FreshnessRules.Combine( [ new Freshness(FreshnessState.Live, Now.AddMinutes(-5)), new Freshness(FreshnessState.Stale, Now.AddDays(-2)), Freshness.None, ]); Assert.Equal(FreshnessState.Stale, combined.State); Assert.Equal(Now.AddDays(-2), combined.LastActivity); Assert.Equal(FreshnessState.Historical, FreshnessRules.Combine([new Freshness(FreshnessState.Historical, Now)]).State); Assert.Equal(FreshnessState.NoData, FreshnessRules.Combine([]).State); } }