using MeterVault.App.Analysis; using MeterVault.App.AnalysisPage; using MeterVault.App.Energy; using MeterVault.App.MeterDetails; using MeterVault.Core.Analysis; 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.Analysis; /// /// Matching scopes and periods reconcile across pages (brief §10 Phase 4 exit, §12): the Overview's type card, the energy /// type page, the Analysis page and the billed meter's own page state the same cost change for the same period — over /// the buckets both periods cover completely (D-07) — and the same standing charges. Frozen clock of 19 September 2026. /// [Collection("Timescale")] public sealed class CostConsistencyTests(TimescaleFixture fx) { private static readonly ComparisonRequest PreviousYear = new(ComparisonKind.PreviousYear); [Fact] public async Task A_partial_period_states_the_same_matched_cost_change_on_every_page() { // Like the seed: data from Sep 2024 to May 2026, so "last 12 months" (Oct 2025 – Sep 2026) is partial. Its cost // change is measured over Oct – May, where both years are complete, and every page says the same. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 9, 1), [.. Enumerable.Range(0, 21).Select(i => 100d + (i * 5))]); await box.TypePriceAsync(type, 0.30, D(2023, 1, 1)); var (reader, costs, periods) = Readers(); var overview = await Dashboard().GetOverviewAsync(Preset(PeriodPreset.Last12Months), BucketSize.Auto, PreviousYear); var onOverview = overview.Types.Single(t => t.Type.Id == type).CostChange; Assert.Equal(CostChangeBasis.MatchedBuckets, onOverview.Basis); Assert.NotNull(onOverview.Change.Absolute); var energyQuery = AnalysisQuery.Parse("?period=12m", AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type))); var energy = await new EnergyAnalysisLoader(fx, periods, reader, costs, new FlowService(fx, Options())).LoadAsync(type, energyQuery, Now); var analysisQuery = AnalysisQuery.Parse($"/trends?scope=type&id={type}&metric=cost&period=12m", AnalysisDefaults.History); var options = await AnalysisPageOptions.LoadAsync(fx, reader); var analysis = await new AnalysisPageLoader(reader, costs, periods) .LoadAsync(analysisQuery, AnalysisSelection.Resolve(analysisQuery, options), options, Now); var meterQuery = AnalysisQuery.Parse("?period=12m", MeterAnalysisLoader.DefaultsFor(meter)); var meterView = await new MeterAnalysisLoader(periods, reader, costs, new MeterDetailService(fx, Options(), reader)).LoadAsync(meter, meterQuery, Now); foreach (var (page, change) in new[] { ("energy", energy.CostChange), ("analysis", analysis.Costs.Single().CostChange), ("meter", meterView.CostChange), }) { Assert.True(onOverview.Basis == change.Basis, page); Assert.Equal(onOverview.Change.Absolute!.Value, change.Change.Absolute!.Value, 6); Assert.Equal(onOverview.Current!.Value, change.Current!.Value, 6); } // The Analysis table's total row states it too, rather than "—". Assert.Equal(onOverview.Change.Absolute!.Value, analysis.Table.Single().TotalChange!.Absolute!.Value, 6); } [Fact] public async Task The_energy_card_counts_the_meter_fees_on_bill_lines_like_every_other_card() { // D-40: a meter fee stays on its meter's bill line. 10 €/month on the type and 3 €/month on the billed meter over // 2024 are 156 € of standing charges — on the energy card as on the Analysis and Overview cards, never 120. await using var box = new CostSandbox(fx); var type = await box.TypeAsync(); var meter = await box.MonthlyAsync(type, MeterMode.CumulativeCounter, D(2024, 1, 1), [.. Enumerable.Repeat(100d, 12)]); await box.TypePriceAsync(type, 0.30, D(2023, 1, 1)); await box.TariffAsync(TariffScope.EnergyType, type, TariffComponent.BasePrice, 10, "EUR/month", D(2023, 1, 1)); await box.TariffAsync(TariffScope.Meter, meter, TariffComponent.BasePrice, 3, "EUR/month", D(2023, 1, 1)); var (reader, costs, periods) = Readers(); var query = AnalysisQuery.Parse("?from=2024-01-01&to=2024-12-31", AnalysisDefaults.History.ForScope(QueryScope.ForEnergyType(type))); var energy = await new EnergyAnalysisLoader(fx, periods, reader, costs, new FlowService(fx, Options())).LoadAsync(type, query, Now); Assert.Equal(156, energy.StandingCharge!.Value, 6); var analysisQuery = AnalysisQuery.Parse($"/trends?scope=type&id={type}&metric=cost&from=2024-01-01&to=2024-12-31", AnalysisDefaults.History); var options = await AnalysisPageOptions.LoadAsync(fx, reader); var analysis = await new AnalysisPageLoader(reader, costs, periods) .LoadAsync(analysisQuery, AnalysisSelection.Resolve(analysisQuery, options), options, Now); Assert.Equal(156, analysis.Costs.Single().Current.Total.StandingCharge!.Value, 6); var overview = await Dashboard().GetOverviewAsync(Custom(D(2024, 1, 1), D(2024, 12, 31)), BucketSize.Auto, PreviousYear); Assert.Equal(156, overview.Types.Single(t => t.Type.Id == type).Cost!.Total.StandingCharge!.Value, 6); } private static Microsoft.Extensions.Options.IOptions Options() => Microsoft.Extensions.Options.Options.Create(new MeterVaultOptions { TimeZone = BerlinId, Currency = "EUR" }); private (AnalysisReader Reader, CostReader Costs, AnalysisPeriods Periods) Readers() { var reader = new AnalysisReader(fx, Options()); var costs = new CostReader(fx, reader, Options()); return (reader, costs, new AnalysisPeriods(reader, costs)); } private DashboardService Dashboard() { var clock = new FixedTimeProvider(Now); return new DashboardService(fx, new CostService(fx, Options(), clock), clock); } }