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