using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using static MeterVault.Core.Tests.Analysis.Costing.CostingTestData;
namespace MeterVault.Core.Tests.Analysis.Costing;
///
/// Cost figures fold exactly: the sum of the parts is the same figure — value, status and missing prices — however it
/// is grouped. And the calculator refuses input it cannot price honestly.
///
public sealed class CostAmountTests
{
[Fact]
public void The_empty_figure_is_priced_with_no_value()
{
Assert.Equal(CostStatus.Priced, CostAmount.Empty.Status);
Assert.Null(CostAmount.Empty.Cost);
Assert.Equal(BucketStatus.Available, CostAmount.Empty.Availability);
Assert.Empty(CostAmount.Empty.MissingPrices);
var sum = CostAmount.Sum([CostAmount.Empty, CostAmount.Empty]);
Assert.Equal((CostStatus.Priced, (double?)null, false), (sum.Status, sum.Cost, sum.IncludesNotPriced));
}
[Fact]
public void Summing_is_associative_over_lines_and_buckets()
{
// A priced grid line, a water line with a gap, an unpriced tank, a standing charge and a manual cost: the bill
// total must be the same whether the buckets or the lines are added first.
var book = Book(
TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)),
TypePrice(2, Water, 5, "EUR/m3", D(2025, 3, 1)),
BasePrice(3, TariffScope.EnergyType, Electricity, 12, "EUR/Monat", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 6, 30));
var result = Price(
buckets,
book,
[Line(Grid, Electricity, Each(buckets, 100)), Line(WaterMeter, Water, Each(buckets, 10), "m³"), Line(OilTank, Oil, Each(buckets, 50), "L")],
standing: [StandingChargeScope.ForEnergyType(Electricity, new ServicePeriod(D(2020, 1, 1)))],
manual: [Manual(1, D(2025, 4, 2), 99)]);
var byLine = CostAmount.Sum(result.Lines.Select(l => l.Total).Concat(result.StandingCharges.Select(r => r.Total)).Append(result.ManualCosts.Total));
var byBucket = CostAmount.Sum(result.Totals);
foreach (var figure in new[] { byLine, byBucket })
{
Assert.Equal(result.Total.Cost!.Value, figure.Cost!.Value, 9);
Assert.Equal(result.Total.Status, figure.Status);
Assert.Equal(result.Total.IncludesNotPriced, figure.IncludesNotPriced);
Assert.Equal(result.Total.MissingPrices, figure.MissingPrices);
}
Assert.Equal(CostStatus.Partial, result.Total.Status);
Assert.Equal((6 * 30) + (4 * 50) + (6 * 12) + 99, result.Total.Cost!.Value, 9);
}
[Fact]
public void Missing_prices_merge_into_one_entry_with_their_first_and_last_month()
{
var book = Book(TypePrice(1, Water, 5, "EUR/m3", D(2025, 6, 1)));
var buckets = Buckets(BucketSize.Day, D(2025, 3, 30), D(2025, 5, 2));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 1), "m³")]);
var missing = Assert.Single(result.MissingPrices);
Assert.Equal((D(2025, 3, 1), D(2025, 5, 1)), (missing.FirstMonth, missing.LastMonth));
// Each day bucket names its own month.
Assert.Equal(D(2025, 4, 1), Assert.Single(result.Totals.At(buckets, D(2025, 4, 17)).MissingPrices).FirstMonth);
}
[Fact]
public void A_unit_mismatch_outranks_a_gap_when_nothing_is_priced()
{
var book = Book(Tariff(1, TariffScope.EnergyType, Water, TariffComponent.UnitPrice, 0.3, "EUR/kWh", D(2025, 2, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 2, 28));
var result = Price(buckets, book, [Line(WaterMeter, Water, Each(buckets, 10), "m³")]);
Assert.Equal(CostStatus.UnitMismatch, result.Total.Status);
Assert.Equal([CostStatus.PriceGap, CostStatus.UnitMismatch], result.MissingPrices.Select(m => m.Reason));
Assert.Null(result.Total.Cost);
}
[Fact]
public void Availability_reports_the_most_telling_reason_when_nothing_is_known()
{
var book = Book(TypePrice(1, Electricity, 0.30, "EUR/kWh", D(2025, 1, 1)));
var buckets = Buckets(BucketSize.Year, D(2025, 1, 1), D(2025, 3, 31));
var parts = CostCalculator.Parts(buckets);
var result = Price(
buckets,
book,
[Line(Grid, Electricity, [
CostQuantity.Unknown(parts[0]),
CostQuantity.Unknown(parts[1], BucketStatus.Unresolved),
CostQuantity.Unknown(parts[2], BucketStatus.Pending)])]);
Assert.Equal(BucketStatus.Pending, result.Total.Availability);
Assert.Null(result.Total.Cost);
}
// ---- refusing malformed input ---------------------------------------------------------------------
[Fact]
public void A_quantity_that_is_not_a_part_of_the_request_is_refused()
{
var buckets = Buckets(BucketSize.Week, D(2025, 1, 27), D(2025, 2, 2));
// The whole week in one quantity would be priced at one month's price: the caller must split it.
var wholeWeek = new CostQuantity(D(2025, 1, 27), D(2025, 2, 3), 70, BucketStatus.Available);
Assert.Throws(() => Price(buckets, Book(), [Line(Grid, Electricity, [wholeWeek])]));
}
[Fact]
public void A_quantity_given_twice_or_not_finite_is_refused()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
var part = CostCalculator.Parts(buckets).Single();
Assert.Throws(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, 1), CostQuantity.Known(part, 2)])]));
Assert.Throws(() => Price(buckets, Book(), [Line(Grid, Electricity, [CostQuantity.Known(part, double.NaN)])]));
}
[Fact]
public void Overlapping_buckets_are_refused()
{
var buckets = new[] { Bucket(D(2025, 1, 1), D(2025, 2, 1)), Bucket(D(2025, 1, 15), D(2025, 1, 16), BucketSize.Day) };
Assert.Throws(() => CostCalculator.Parts(buckets));
}
[Fact]
public void A_standing_charge_row_names_its_scope_and_a_meter_with_a_line_is_not_a_row()
{
var buckets = Buckets(BucketSize.Month, D(2025, 1, 1), D(2025, 1, 31));
// A-18: a meter's own row is for a meter without a line; with one, its charge accrues on the line.
Assert.Throws(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.Meter, null, null)]));
Assert.Throws(() => Price(
buckets, Book(), [Line(Grid, Electricity, Each(buckets, 1))], standing: [StandingChargeScope.ForMeter(Grid, null)]));
Assert.Throws(() => Price(buckets, Book(), [], standing: [new StandingChargeScope(TariffScope.EnergyType, null, null)]));
Assert.Throws(() => Price(
buckets, Book(), [], standing: [StandingChargeScope.Global(null), StandingChargeScope.Global(null)]));
}
[Fact]
public void A_service_period_cannot_end_before_it_starts()
{
Assert.Throws(() => new ServicePeriod(D(2025, 2, 1), D(2025, 1, 31)));
}
[Fact]
public void An_empty_request_prices_nothing()
{
var result = Price([], Book(), []);
Assert.Empty(result.Totals);
Assert.Null(result.Total.Cost);
Assert.Equal("EUR", result.Currency);
}
}