using System.Globalization; using MeterVault.App.Analysis; using MeterVault.App.Localization; using MeterVault.App.MeterDetails; using MeterVault.Core.Analysis; using MeterVault.Core.Analysis.Virtual; 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 static MeterVault.Integration.Tests.Costing.CostSandbox; namespace MeterVault.Integration.Tests.MeterPage; /// /// The meter page's Analysis tab load (brief §7.2, §5.4): one committed value per request — the series from the shared /// reader, the cost by the meter's rule, the comparison, the chart and table inputs — for a physical and a virtual /// meter alike, on a frozen clock of 19 September 2026, 14:37 Berlin. Replaces MeterPeriodServiceTests (the service is /// retired): the same cases, read the way the page reads them now. /// [Collection("Timescale")] public sealed class MeterAnalysisLoaderTests(TimescaleFixture fx) { [Fact] public async Task A_virtual_sum_of_two_generation_meters_reads_250_and_200_by_month_and_450_in_total() { // Brief §5.4, the worked example: A = 100/80, B = 150/120 → A+B = 250/200, 450, generation, no raw readings, no // cost category — and the page labels it generation. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var a = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 100, 80); var b = await box.MonthlyAsync(type, MeterMode.GenerationCounter, D(2026, 1, 1), 150, 120); var sum = await box.VirtualAsync(type, $"m{a} + m{b}", QuantityKind.Generation, "kWh", VirtualCostRule.None); var view = await InEnglish(() => Loader(box).LoadAsync(sum, JanFeb(sum), Now)); Assert.Equal(sum, view.MeterId); var series = view.Series!; Assert.Equal(SeriesBasis.Virtual, series.Basis); Assert.Equal(QuantityKind.Generation, series.Kind); Assert.Equal("Generation", InEnglishSync(() => series.Kind.Display())); Assert.Equal("kWh", series.Unit); Assert.Equal([250d, 200d], series.Values.Select(v => v.Value!.Value)); Assert.All(series.Values, v => Assert.Equal(BucketStatus.Available, v.Status)); Assert.Equal(450d, series.Total.Value!.Value, 6); Assert.Equal(BucketStatus.Available, series.Total.Status); Assert.Equal(2, series.Contributions.Count); // The chart and table say the same, with the months as their rows. var table = Assert.Single(view.Table); Assert.Equal(["250 kWh", "200 kWh"], table.Values.Select(v => v.Text)); Assert.Equal("450 kWh", table.Total!.Text); Assert.Equal([250d, 200d], Assert.Single(view.Chart).Values.Select(v => v.Value!.Value)); // Generation is never billed: the meter is not costed, and the page offers no cost metric. Assert.NotNull(view.Cost); Assert.False(view.IsCosted); Assert.Equal(MeterCostRule.None, view.Cost!.Meter!.Rule); Assert.Empty(view.Metrics); Assert.Null(table.Costs); } [Fact] public async Task A_physical_meter_reads_local_months_its_comparison_and_its_cost() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2025, 7, 1)); await box.ReadingsAsync(meter, (Midnight(2025, 7, 1), 0), (Midnight(2025, 8, 1), 0), (Midnight(2025, 9, 1), 80), (Midnight(2025, 10, 1), 150), (Midnight(2026, 8, 1), 1000), (Midnight(2026, 9, 1), 1100), (Midnight(2026, 9, 10), 1130)); await box.TypePriceAsync(type, 0.10, D(2025, 1, 1)); await using (var db = fx.CreateContext()) { db.MeterEvents.Add(new MeterEvent { MeterId = meter, Time = Midnight(2026, 8, 15), EventType = MeterEventType.Note, Notes = "new tenant" }); await db.SaveChangesAsync(); } var query = AnalysisQuery.Parse("?from=2026-08-01&to=2026-08-31&bucket=month", MeterAnalysisLoader.DefaultsFor(meter)); var view = await InEnglish(() => Loader(box).LoadAsync(meter, query, Now)); var series = view.Series!; Assert.Equal(100d, series.Total.Value!.Value, 6); Assert.Equal(QuantityKind.Consumption, series.Kind); // Compared with August last year (A-13 default), over what both cover. Assert.NotNull(series.Comparison); Assert.Equal(80d, series.Comparison!.Total.Value!.Value, 6); Assert.Equal(20d, series.Comparison.Change.Absolute!.Value, 6); // Its cost is its bill line at the type's price, and the change of the cost pairs complete months only. Assert.True(view.IsCosted); Assert.Equal(MeterCostRule.BillLine, view.Cost!.Meter!.Rule); CostAssert.Priced(10, view.Cost.Total); Assert.Equal(2d, view.CostChange.Change.Absolute!.Value, 6); Assert.Equal([AnalysisMetric.Consumption, AnalysisMetric.Cost], view.Metrics); Assert.NotNull(Assert.Single(view.Table).Costs); // The note recorded in the month is a marker under the chart. Assert.Equal("new tenant", Assert.Single(view.Markers.Events).Notes); // metric=cost charts the cost and its comparison instead. var costView = await InEnglish(() => Loader(box).LoadAsync(meter, query.WithMetric(AnalysisMetric.Cost), Now)); Assert.True(costView.ShowsCost); Assert.Equal(2, costView.Chart.Count); Assert.True(costView.Chart.All(c => c.IsMoney)); Assert.Equal([10d], costView.Chart[0].Values.Select(v => v.Value!.Value)); Assert.Equal([8d], costView.Chart[1].Values.Select(v => v.Value!.Value)); } [Fact] public async Task A_reading_after_now_is_not_counted_and_the_month_is_not_a_confident_zero_before_the_data() { // D-04: a reading stamped on 25 September (a device clock ahead) is not an actual on the 19th. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2026, 8, 1)); await box.ReadingsAsync( meter, (Midnight(2026, 8, 1), 0), (Midnight(2026, 9, 1), 100), (Midnight(2026, 9, 10), 130), (Midnight(2026, 9, 25), 1000)); var ytd = AnalysisQuery.Parse("?period=ytd&bucket=month", MeterAnalysisLoader.DefaultsFor(meter)); var view = await InEnglish(() => Loader(box).LoadAsync(meter, ytd, Now)); var series = view.Series!; Assert.Equal(130d, series.Total.Value!.Value, 6); Assert.Contains(series.RecordedAfterNow, r => r.MeterId == meter); // The months before the install hold no data: missing, not zero. Assert.Equal(BucketStatus.Missing, series.Values[0].Status); Assert.Null(series.Values[0].Value); Assert.Equal("—", view.Table[0].Values[0].Text); } [Fact] public async Task A_year_of_measured_zeros_is_data_and_a_generation_meter_reports_generation() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var zeros = await box.MeterAsync(type, MeterMode.CumulativeCounter, "kWh", installedAt: D(2026, 5, 1)); await box.ReadingsAsync(zeros, (Midnight(2026, 5, 1), 0), (Midnight(2026, 6, 1), 0), (Midnight(2026, 7, 1), 0), (Midnight(2026, 8, 1), 0)); var pv = await box.MeterAsync(type, MeterMode.GenerationCounter, "kWh", installedAt: D(2026, 6, 1)); await box.ReadingsAsync(pv, (Midnight(2026, 6, 1), 0), (Midnight(2026, 7, 1), 42)); await box.TypePriceAsync(type, 0.10, D(2025, 1, 1)); var june = AnalysisQuery.Parse("?from=2026-06-01&to=2026-07-31&bucket=month&compare=none", MeterAnalysisLoader.DefaultsFor(zeros)); var zeroView = await InEnglish(() => Loader(box).LoadAsync(zeros, june, Now)); Assert.All(zeroView.Series!.Values, v => Assert.Equal((BucketStatus.Available, 0d), (v.Status, v.Value!.Value))); Assert.Equal(0d, zeroView.Series!.Total.Value!.Value, 9); var pvView = await InEnglish(() => Loader(box).LoadAsync(pv, june, Now)); Assert.Equal(QuantityKind.Generation, pvView.Series!.Kind); Assert.Equal(42d, pvView.Series.Values[0].Value!.Value, 6); Assert.False(pvView.IsCosted); Assert.Equal(MeterNotCostedReason.Generation, pvView.Cost!.Meter!.NotCosted); } [Fact] public async Task A_virtual_meter_that_cannot_be_evaluated_shows_no_values_and_is_not_costed() { await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80); var broken = await box.VirtualAsync(type, $"m{a} + m{int.MaxValue}", QuantityKind.Consumption, "kWh", VirtualCostRule.SourceCosts); var view = await InEnglish(() => Loader(box).LoadAsync(broken, JanFeb(broken), Now)); var series = view.Series!; Assert.Equal(BucketStatus.Invalid, series.Total.Status); Assert.Null(series.Total.Value); Assert.All(series.Values, v => Assert.Null(v.Value)); Assert.Equal(VirtualMeterStatus.Invalid, series.Virtual!.Status); Assert.Contains(view.Quantities.Problems, p => p.Kind == AnalysisProblemKind.InvalidDefinition && p.MeterId == broken); Assert.False(view.IsCosted); } [Fact] public async Task A_dependency_loop_names_the_other_meter_by_its_name_never_by_its_id() { // Brief §4.3 / §11 "a cycle reports a named dependency error": a loop stops the evaluation, so the other end of it // is in no contribution — the attention item and the figures still name it. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var a = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2026, 1, 1), 100, 80); var one = await box.VirtualAsync(type, $"m{a}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity); var two = await box.VirtualAsync(type, $"m{one}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity); await using (var db = fx.CreateContext()) { var meter = await db.Meters.FindAsync(one); meter!.Meta = VirtualDefinitionJson.Write("{}", new VirtualDefinition($"m{a} + m{two}", QuantityKind.Consumption, "kWh", VirtualCostRule.OwnQuantity)); await db.SaveChangesAsync(); } string twoName; await using (var db = fx.CreateContext()) { twoName = (await db.Meters.FindAsync(two))!.Name; } var view = await InEnglish(() => Loader(box).LoadAsync(one, JanFeb(one), Now)); Assert.Equal(BucketStatus.Invalid, view.Series!.Total.Status); Assert.Equal(twoName, view.MeterNames[two]); var items = InEnglishSync(() => AttentionItems.Build( view.Quantities.Problems.Concat(view.Cost?.QuantityProblems ?? []), view.Cost?.Attention, view.AttentionNames, view.Query)); var loop = Assert.Single(items, i => i.Text.Contains(" → ", StringComparison.Ordinal)); Assert.Contains(twoName, loop.Text, StringComparison.Ordinal); Assert.DoesNotContain("#" + two.ToString(CultureInfo.InvariantCulture), loop.Text, StringComparison.Ordinal); Assert.DoesNotContain("Meter #", loop.Text, StringComparison.Ordinal); // The figures that name a culprit name it too. var details = view.Table.Single().Values.Select(v => v.Status.Detail).Append(view.Table.Single().Total!.Status.Detail).OfType().ToList(); Assert.DoesNotContain(details, d => d.Contains('#', StringComparison.Ordinal)); } [Fact] public async Task A_meter_that_is_being_rebuilt_is_pending_and_nothing_is_priced() { // D-16: hand-inserted consumption without rollups reads as "being prepared", never as no data. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MeterAsync(type, MeterMode.DirectDelta, "kWh"); await using (var db = fx.CreateContext()) { db.Consumption.Add(new Consumption { MeterId = meter, Time = Midnight(2026, 2, 1), Amount = 5, Kind = ConsumptionKind.Consumption, Quality = ReadingQuality.Measured }); await db.SaveChangesAsync(); } var view = await InEnglish(() => Loader(box).LoadAsync(meter, JanFeb(meter), Now)); Assert.True(view.Series!.IsPending); Assert.Null(view.Cost); } private static AnalysisQuery JanFeb(int meterId) => AnalysisQuery.Parse("?from=2026-01-01&to=2026-02-28&bucket=month&compare=none", MeterAnalysisLoader.DefaultsFor(meterId)); private MeterAnalysisLoader Loader(CostSandbox box) { var options = Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" }); var reader = new AnalysisReader(fx, options); var costs = new CostReader(fx, reader, options); _ = box; return new MeterAnalysisLoader(new AnalysisPeriods(reader, costs), reader, costs, new MeterDetailService(fx, options, reader)); } /// Runs a load with English as the formatting and UI culture (the chart and table text is built inside it). private static async Task InEnglish(Func> load) { var format = CultureInfo.CurrentCulture; var ui = CultureInfo.CurrentUICulture; CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en"); CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en"); try { return await load(); } finally { CultureInfo.CurrentCulture = format; CultureInfo.CurrentUICulture = ui; } } private static T InEnglishSync(Func body) => Analysis.AnalysisUiTestData.In("en", body); }