using MeterVault.App.Analysis; using MeterVault.App.Energy; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Coverage; using MeterVault.Core.Analysis.Totals; 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 MeterVault.App.Localization; using MeterVault.App.Theme; using static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.Analysis; /// /// A household whose heating oil is read a few times a year — a tank dipped with a stick and a burner whose hours are /// noted now and then — read exactly as the energy type page reads it (A-41, reported from a real instance). /// /// /// /// Such a meter measures more coarsely than any bucket a chart can draw, and its last reading is often months old. /// Three things went wrong before this fixture existed: /// /// /// /// A period that opened after the last dipstick had no coverage at all, so the type's use read "No data" beside a /// coverage panel listing years of it, while the burner next to it read "Only coarser data" (D-14). /// /// /// Automatic buckets answered "last 12 months" with one bar per year, because data coarser than a month dragged the /// whole chart to years — which left it exactly as unresolved (D-05, A-06). /// /// /// The type's comparison line spoke of the use measure alone, so it claimed the periods shared no covered days while /// the table below compared the burner over the days both cover (D-07). /// /// /// Everything runs on the sandbox's frozen clock of 19 September 2026, 14:37 Berlin. /// [Collection("Timescale")] public sealed class CoarseMeterAnalysisTests(TimescaleFixture fx) { /// The tank's dipsticks: once a year, in litres, with a delivery between two of them. private static readonly (DateOnly Day, double Litres)[] Dipsticks = [ (D(2022, 10, 14), 5200), (D(2023, 9, 2), 2100), (D(2024, 10, 5), 2600), (D(2025, 9, 20), 3100), ]; /// /// The burner's hour counter, read every few months — around the same days each year, so its intervals do share /// covered days with the year before (which the tank's, dipped once every autumn, never do). /// private static readonly (DateOnly Day, double Hours)[] BurnerHours = [ (D(2022, 10, 14), 1000), (D(2023, 9, 2), 2100), (D(2024, 1, 5), 2500), (D(2024, 5, 1), 2900), (D(2024, 8, 1), 2980), (D(2024, 10, 5), 3300), (D(2025, 1, 5), 3860), (D(2025, 5, 1), 4286), (D(2025, 8, 1), 4370), (D(2025, 10, 5), 4700), (D(2026, 1, 5), 5240), (D(2026, 5, 1), 5629), (D(2026, 8, 1), 5700), ]; [Fact] public async Task A_tank_dipped_once_a_year_says_its_data_is_coarser_instead_of_claiming_there_is_none() { await using var box = new CostSandbox(fx); var (type, tank, burner) = await HeatingOilAsync(box); var page = await LoadAsync(box, type, "/energy/1?period=12m"); var quantities = page.Quantities!; var use = Assert.Single(quantities.Measures, m => m.Key.Measure == TotalsMeasure.Use); var runtime = Assert.Single(quantities.Measures, m => m.Key.Measure == TotalsMeasure.Runtime); // The tank was last dipped eleven days before the period opened, so nothing covers it — but it is read once a // year, not silent. It says so, in the same words as the burner beside it, and with the same resolution. Assert.Equal([tank], use.MemberIds); Assert.Equal(BucketStatus.Unresolved, use.Total.Status); Assert.Equal(ValueIssue.CoarseResolution, use.Total.Issue); Assert.Equal(ResolutionClass.Coarse, use.Resolution); Assert.Null(use.Total.Value); Assert.Equal(BucketStatus.Unresolved, runtime.Total.Status); Assert.Equal(ResolutionClass.Coarse, runtime.Resolution); Assert.All(use.Values, v => Assert.Equal(BucketStatus.Unresolved, v.Status)); // The card, the coverage panel and the empty chart say one thing, and it is not "no data". AnalysisUiTestData.In("en", () => { var status = FigureText.Of(use.Total); Assert.Equal("Only coarser data", status.Status); Assert.False(status.IsKnown); Assert.Equal(ChartEmptyReason.Unresolved, ChartPlanOf(quantities, use).EmptyReason); Assert.Equal("Coarser than monthly", use.Resolution!.Value.Display()); }); // Its own coverage still reaches back years, which is what made "no data" read as a contradiction. Assert.Equal((D(2022, 10, 14), D(2025, 9, 19)), (use.Availability!.FirstDay, use.Availability.LastDay)); Assert.Equal(D(2026, 7, 31), quantities.Availability.Quantity!.LastDay); Assert.DoesNotContain(quantities.Measures, m => m.Total.Status == BucketStatus.Missing); Assert.Equal([tank, burner], quantities.Series.Select(s => s.MeterId!.Value).Order()); } [Fact] public async Task Automatic_buckets_answer_twelve_months_with_twelve_months() { await using var box = new CostSandbox(fx); var (type, _, _) = await HeatingOilAsync(box); var page = await LoadAsync(box, type, "/energy/1?period=12m"); // A-41: data coarser than a month must not coarsen the chart to a bar per year; a year holds a dipstick's // interval no better than a month does, and one bar is not a trend. Assert.Equal(BucketSize.Auto, page.Quantities!.Plan.Requested); Assert.Equal(BucketSize.Month, page.Quantities.Plan.Size); Assert.Equal(12, page.Quantities.Plan.Buckets.Count); Assert.Equal(D(2025, 10, 1), page.Quantities.Plan.Buckets[0].FirstDay); Assert.Equal(page.Quantities.Plan.Buckets, page.Cost!.Plan.Buckets); } [Fact] public async Task A_meter_read_on_the_same_days_every_year_compares_and_the_type_line_says_which_figure_it_means() { await using var box = new CostSandbox(fx); var (type, tank, burner) = await HeatingOilAsync(box); var page = await LoadAsync(box, type, "/energy/1?period=12m"); var use = Assert.Single(page.Quantities!.Measures, m => m.Key.Measure == TotalsMeasure.Use); var burnerSeries = Assert.Single(page.Quantities.Series, s => s.MeterId == burner); // The burner shares covered days with the year before; the tank, dipped every autumn, shares none. One page, // two true statements — so the comparison line names the figure it is about, and the largest-changes table // below states its own basis (D-07). Assert.True(page.Quantities.Comparison!.Resolution.IsApplicable); Assert.True(burnerSeries.Comparison!.Matched.IsComparable); Assert.False(use.Comparison!.Matched.IsComparable); Assert.Equal([burner], MeterChanges.Largest(page.Quantities.Series, 6).Select(c => c.Series.MeterId!.Value)); Assert.DoesNotContain(MeterChanges.Largest(page.Quantities.Series, 6), c => c.Series.MeterId == tank); } [Fact] public async Task The_overview_the_analysis_page_and_the_tank_view_read_the_same_coarse_data() { await using var box = new CostSandbox(fx); var (type, tank, _) = await HeatingOilAsync(box); await using (var db = fx.CreateContext()) { db.Tanks.Add(new Tank { MeterId = tank, Capacity = 7000, Unit = "L" }); await db.SaveChangesAsync(); } var period = Preset(PeriodPreset.Last12Months); var reader = Reader(); var clock = new FixedTimeProvider(CostSandbox.Now); // The Analysis page's own scope (/trends?scope=meter&id=…) reads the tank exactly as the type does. var trends = await reader.ReadAsync(new AnalysisRequest(AnalysisScope.ForMeter(tank), period) { Bucket = BucketSize.Auto }); var series = trends.SeriesFor(tank)!; Assert.Equal(BucketSize.Month, trends.Plan.Size); Assert.Equal(BucketStatus.Unresolved, series.Total.Status); Assert.Equal(ResolutionClass.Coarse, series.Resolution); // Tanks & consumables: usage is "only coarser data", never an empty state saying there is none. var consumables = await new ConsumableService(fx, reader, new CostReader(fx, reader, OptionsOf())) .GetAsync(new ConsumableRequest(period) { Bucket = BucketSize.Month }); var view = Assert.Single(consumables.Tanks, t => t.MeterId == tank); Assert.Equal(BucketStatus.Unresolved, view.Usage!.Total.Status); // The Overview's energy-type card reads the same measure. var overview = await new DashboardService(fx, new CostService(fx, OptionsOf(), clock), clock) .GetOverviewAsync(period, BucketSize.Month, new ComparisonRequest(ComparisonKind.PreviousYear)); var card = Assert.Single(overview.Quantities.Measures, m => m.Key.EnergyTypeId == type && m.Key.Measure == TotalsMeasure.Use); Assert.Equal(BucketStatus.Unresolved, card.Total.Status); Assert.False(overview.HasNoData); } // ------------------------------------------------------------------------------------------------ fixture /// The reported instance: a dipstick tank and a burner hour counter in one energy type. private async Task<(short Type, int Tank, int Burner)> HeatingOilAsync(CostSandbox box) { var type = await box.TypeAsync("L"); var tank = await box.MeterAsync(type, MeterMode.ConsumableBalance, "L", D(2022, 10, 14), name: "Öltank"); await using (var db = fx.CreateContext()) { foreach (var (day, litres) in Dipsticks) { db.MeterEvents.Add(Event(tank, MeterEventType.TankLevel, day, litres)); } db.MeterEvents.Add(Event(tank, MeterEventType.Delivery, D(2023, 9, 10), 4000)); await db.SaveChangesAsync(); } await box.RecomputeAsync(tank); var burner = await box.MeterAsync(type, MeterMode.RuntimeCounter, "h", D(2022, 10, 14), name: "Brenner"); await box.ReadingsAsync(burner, [.. BurnerHours.Select(r => (Midnight(r.Day.Year, r.Day.Month, r.Day.Day), r.Hours))]); return (type, tank, burner); } private static MeterEvent Event(int meterId, MeterEventType kind, DateOnly day, double amount) => new() { MeterId = meterId, EventType = kind, Time = Midnight(day.Year, day.Month, day.Day).ToUniversalTime(), Amount = amount, Unit = "L", }; private async Task LoadAsync(CostSandbox box, short type, string uri) { var reader = Reader(); var costs = new CostReader(fx, reader, OptionsOf()); var loader = new EnergyAnalysisLoader(fx, new AnalysisPeriods(reader, costs), reader, costs, new FlowService(fx, OptionsOf())); var query = AnalysisQuery.Parse(uri, AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type))); return await loader.LoadAsync(type, query, CostSandbox.Now); } private AnalysisReader Reader() => new(fx, OptionsOf()); private static Microsoft.Extensions.Options.IOptions OptionsOf() => Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" }); /// The chart the page draws for a measure, to read why it is empty (A-28). private static AnalysisChartPlan ChartPlanOf(AnalysisResult result, AnalysisSeries measure) => AnalysisChartPlan.Build(result.Plan.Buckets, [AnalysisChartSeries.ForSeries(measure, "Total use")], ChartPalette.For(isDark: true)); }