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