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