ci / build-test (push) Successful in 2m31s
The dashboards told several stories at once. Overview asked for full calendar years, meter detail for a fixed 12-month window that was really 13, Trends for 24 months with an Apply button, and the energy pages for 60. Each page derived "today" from UTC, so the first hours of a local day belonged to yesterday. A missing tariff, a month nobody measured and a genuine zero all rendered as 0. And a virtual meter -- the one thing the spreadsheet leans on hardest -- was excluded from analysis outright: MeterPeriodService returned null for it and the page offered a flow diagram instead. docs/DASHBOARD_ANALYSIS_CHANGE_BRIEF.md is the work order. Every choice it left open is settled in docs/ANALYSIS_IMPLEMENTATION_NOTE.md as D-01..D-58 plus amendments A-01..A-30; code, tests and release notes cite those ids. The analysis layer Core/Analysis holds the pure rules: period presets resolved once in the instance zone into a local date range and a half-open UTC range, bucket plans, calendar-unit comparisons, coverage runs with a resolution class, normalized quantities and units, the totals policy, the virtual formula parser/validator/evaluator, and the cost calculator. "Now" comes from TimeProvider; services never read the clock. Normalization now writes, in the same transaction as consumption and by diff, per-meter rollups by local day and month plus coverage runs and a rollup state (AnalysisDataWriter). AnalysisReader answers a request from those tables -- month rollups for month and year buckets, day rollups otherwise, at most two partial edge days from consumption -- and CostReader prices the result month by month. Pages, /api/v1 and the CSV export read nothing else. The unused continuous aggregates are dropped. The reader's statement count per request is constant whether it covers one meter or a thousand. On a synthetic 1,000-meter, ten-year instance the brief's target request (100 meters, ten years, monthly) takes 374 ms against a two-second target, and the Overview went from 48,244 SQL statements per load to 205. Missing is not zero Every bucket carries a status -- available, partial, missing, unresolved, invalid, pending -- derived from coverage, never from the amount, with provenance and a reason code beside it. A true zero is a number and a bar on the baseline; an unknown bucket is a gap that says why; a month whose data only exists monthly says so instead of inventing daily detail; a scope with no tariff says "not priced" instead of 0. Rows whose interval closes after now are reported separately rather than counted. Virtual meters are analysis subjects A virtual meter stores a canonical definition -- expression over m<id> references, result kind, unit and cost rule -- validated on save and on read for syntax, unknown or self references, loops and unit/kind rules. It is evaluated on read from its sources' rollups over their joint coverage: a missing source makes the bucket missing, an observed zero is a valid input, a non-finite result is invalid with its dependency path, and the page lists each source's contribution. Topology links are topology only and never rewrite a saved calculation; expression-less meters from older installs are converted once at startup. The editor has Sum, Difference and Advanced modes with a live preview. Totals and the bill Per energy type the totals policy separates use, grid import, export, generation and runtime, marks breakdown meters as breakdowns and virtual meters as views, and never adds across units. The bill follows it: grid import where there is one, separately priced subsections at their own price, feed-in only on export meters, standing charges once per scope per local day, manual costs once on their start day, categories as non-overlapping covers whose composition reconciles to the bill. The seeded demo's yearly totals now match the spreadsheet. Pages and navigation The period lives in the URL and every page reads the same contract, so a link, a reload and the browser's Back button keep it. Shared components carry it: page header with breadcrumbs, period toolbar, theme-aware chart with an accessible table beside it, metric cards, comparison and availability states, attention items that each link to the one action that fixes them. Meter detail leads with an Analysis tab and resolves its tabs by key; the energy page has Overview, History, Flow and Meters; the old cost-only Trends page is a general Analysis page over portfolio, type, category, meter or a meter comparison. Records tabs are paged server-side instead of showing the latest 200. Everything is English and German, light and dark, down to 360px. Some figures change on purpose; docs/RELEASE_NOTES.md lists each one and what the first start after the update does (it rebuilds all analysis data before the web server listens). docs/SDD.md and CLAUDE.md describe the system as it now is. Tests: 1,733 Core and 746 integration, all green, plus an opt-in performance suite with a synthetic 1,000-meter generator.
745 lines
30 KiB
C#
745 lines
30 KiB
C#
using MeterVault.Core.Analysis.Quantities;
|
|
using MeterVault.Core.Domain;
|
|
|
|
namespace MeterVault.Core.Tests.Analysis;
|
|
|
|
public sealed class TariffUnitTests
|
|
{
|
|
// ---- Parsing ----------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", "EUR", 1, "kWh", 1)]
|
|
[InlineData("€/kWh", "EUR", 1, "kWh", 1)]
|
|
[InlineData("eur/kwh", "EUR", 1, "kWh", 1)]
|
|
[InlineData("Euro/kWh", "EUR", 1, "kWh", 1)]
|
|
[InlineData("ct/kWh", "ct", 0.01, "kWh", 1)]
|
|
[InlineData("Cent/kWh", "ct", 0.01, "kWh", 1)]
|
|
[InlineData("EUR/MWh", "EUR", 1, "MWh", 1)]
|
|
[InlineData("EUR/m3", "EUR", 1, "m³", 1)]
|
|
[InlineData("EUR/m³", "EUR", 1, "m³", 1)]
|
|
[InlineData("EUR/cbm", "EUR", 1, "m³", 1)]
|
|
[InlineData("EUR/100L", "EUR", 1, "L", 100)]
|
|
[InlineData("EUR / 100 l", "EUR", 1, "L", 100)]
|
|
[InlineData("EUR/1.000 L", "EUR", 1, "L", 1000)]
|
|
[InlineData("EUR/1000L", "EUR", 1, "L", 1000)]
|
|
[InlineData("EUR/hl", "EUR", 1, "hL", 1)]
|
|
[InlineData("EUR/h", "EUR", 1, "h", 1)]
|
|
[InlineData("EUR/Std", "EUR", 1, "h", 1)]
|
|
[InlineData("EUR/t", "EUR", 1, "t", 1)]
|
|
[InlineData("EUR per kWh", "EUR", 1, "kWh", 1)]
|
|
[InlineData("EUR je 100 L", "EUR", 1, "L", 100)]
|
|
[InlineData("SEK/kWh", "SEK", 1, "kWh", 1)]
|
|
[InlineData("Fr./kWh", "CHF", 1, "kWh", 1)]
|
|
[InlineData("Rp./kWh", "Rp", 0.01, "kWh", 1)]
|
|
[InlineData("p/kWh", "p", 0.01, "kWh", 1)]
|
|
public void A_quantity_price_parses_to_currency_scale_and_denominator(
|
|
string unit, string currency, double currencyScale, string denominator, double amount)
|
|
{
|
|
var parsed = TariffUnit.Parse(unit);
|
|
|
|
Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis);
|
|
Assert.True(parsed.IsRecognised);
|
|
Assert.Equal(currency, parsed.Currency);
|
|
Assert.Equal(currencyScale, parsed.CurrencyScale);
|
|
Assert.Equal(denominator, parsed.Denominator);
|
|
Assert.Equal(amount, parsed.DenominatorAmount);
|
|
Assert.Null(parsed.Period);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/month", BillingPeriod.Month)]
|
|
[InlineData("EUR/Monat", BillingPeriod.Month)]
|
|
[InlineData("€/Mon.", BillingPeriod.Month)]
|
|
[InlineData("€ pro Monat", BillingPeriod.Month)]
|
|
[InlineData("EUR monatlich", BillingPeriod.Month)]
|
|
[InlineData("EUR/1 Monat", BillingPeriod.Month)]
|
|
[InlineData("EUR/Jahr", BillingPeriod.Year)]
|
|
[InlineData("EUR/year", BillingPeriod.Year)]
|
|
[InlineData("EUR/a", BillingPeriod.Year)]
|
|
[InlineData("EUR p.a.", BillingPeriod.Year)]
|
|
[InlineData("EUR jährlich", BillingPeriod.Year)]
|
|
[InlineData("EUR/Tag", BillingPeriod.Day)]
|
|
[InlineData("EUR/day", BillingPeriod.Day)]
|
|
[InlineData("EUR/d", BillingPeriod.Day)]
|
|
[InlineData("EUR/12 Monate", BillingPeriod.Year)]
|
|
[InlineData("EUR/1 Jahr", BillingPeriod.Year)]
|
|
[InlineData("EUR/Quartal", BillingPeriod.Quarter)]
|
|
[InlineData("EUR/quarter", BillingPeriod.Quarter)]
|
|
[InlineData("EUR pro Quartal", BillingPeriod.Quarter)]
|
|
[InlineData("EUR vierteljährlich", BillingPeriod.Quarter)]
|
|
[InlineData("EUR/3 Monate", BillingPeriod.Quarter)]
|
|
public void A_standing_charge_parses_to_its_billing_period(string unit, BillingPeriod period)
|
|
{
|
|
var parsed = TariffUnit.Parse(unit);
|
|
|
|
Assert.Equal(TariffUnitBasis.Period, parsed.Basis);
|
|
Assert.Equal(period, parsed.Period);
|
|
Assert.Equal("EUR", parsed.Currency);
|
|
Assert.Null(parsed.Denominator);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("pauschal")]
|
|
[InlineData("EUR")]
|
|
[InlineData("kWh")]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
[InlineData(null)]
|
|
[InlineData("EUR/100")]
|
|
[InlineData("EUR/kWh/h")]
|
|
[InlineData("EUR/0 L")]
|
|
[InlineData("EUR/1,5 L")]
|
|
[InlineData("EUR/1.00 L")]
|
|
[InlineData("kWh/EUR")]
|
|
[InlineData("Taler/kWh")]
|
|
[InlineData("(brutto)")]
|
|
[InlineData("brutto EUR/kWh")]
|
|
public void Anything_else_is_unparseable(string? unit)
|
|
{
|
|
var parsed = TariffUnit.Parse(unit);
|
|
|
|
Assert.Equal(TariffUnitBasis.Unparseable, parsed.Basis);
|
|
Assert.False(parsed.IsRecognised);
|
|
Assert.Null(parsed.Denominator);
|
|
Assert.Null(parsed.Period);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unknown_denominator_is_kept_to_match_itself()
|
|
{
|
|
var parsed = TariffUnit.Parse("EUR/Stk");
|
|
|
|
Assert.Equal(TariffUnitBasis.OtherDenominator, parsed.Basis);
|
|
Assert.Equal("Stk", parsed.Denominator);
|
|
Assert.False(parsed.IsRecognised);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/100L", "100 L")]
|
|
[InlineData("EUR/m3", "m³")]
|
|
[InlineData("EUR/Monat", "month")]
|
|
[InlineData("EUR/a", "year")]
|
|
[InlineData("pauschal", "pauschal")]
|
|
public void The_denominator_text_says_what_the_price_is_quoted_per(string unit, string text)
|
|
{
|
|
Assert.Equal(text, TariffUnit.Parse(unit).DenominatorText);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", TariffComponent.UnitPrice, true)]
|
|
[InlineData("ct/kWh", TariffComponent.FeedIn, true)]
|
|
[InlineData("EUR/month", TariffComponent.UnitPrice, false)]
|
|
[InlineData("EUR/Stk", TariffComponent.UnitPrice, true)]
|
|
[InlineData("pauschal", TariffComponent.UnitPrice, false)]
|
|
[InlineData("EUR/month", TariffComponent.BasePrice, true)]
|
|
[InlineData("EUR/Jahr", TariffComponent.BasePrice, true)]
|
|
[InlineData("EUR/kWh", TariffComponent.BasePrice, false)]
|
|
[InlineData("pauschal", TariffComponent.BasePrice, false)]
|
|
[InlineData("%", TariffComponent.Tax, true)]
|
|
public void The_editor_accepts_only_a_unit_shaped_for_its_component(string unit, TariffComponent component, bool suits)
|
|
{
|
|
Assert.Equal(suits, TariffUnit.Parse(unit).Suits(component));
|
|
}
|
|
|
|
// ---- Unit and feed-in prices --------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", "kWh", 1)]
|
|
[InlineData("€/kWh", "kWh", 1)]
|
|
[InlineData("ct/kWh", "kWh", 0.01)]
|
|
[InlineData("EUR/100L", "L", 0.01)]
|
|
[InlineData("EUR/MWh", "kWh", 0.001)]
|
|
[InlineData("EUR/m3", "m³", 1)]
|
|
[InlineData("EUR/m3", "m3", 1)]
|
|
[InlineData("EUR/m³", "L", 0.001)]
|
|
[InlineData("EUR/100L", "m³", 10)]
|
|
[InlineData("EUR/kWh", "Wh", 0.001)]
|
|
[InlineData("EUR/kWh", "MWh", 1000)]
|
|
[InlineData("ct/100 L", "L", 0.0001)]
|
|
[InlineData("EUR/h", "h", 1)]
|
|
[InlineData("EUR/t", "kg", 0.001)]
|
|
public void A_matching_unit_price_applies_with_the_factor_to_currency_per_meter_unit(
|
|
string tariffUnit, string meterUnit, double factor)
|
|
{
|
|
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.True(fit.Applies);
|
|
Assert.False(fit.NeedsWarning);
|
|
Assert.Equal(TariffUnitIssue.None, fit.Issue);
|
|
Assert.Equal(factor, fit.Factor, 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_seeded_water_tariff_prices_the_seeded_water_meter_at_face_value()
|
|
{
|
|
// Seed: water 5.00 "EUR/m3" on a meter whose unit is "m3". Dec 2022 is 14 m³ = 70 € (D-56).
|
|
var fit = TariffUnit.Applicability("EUR/m3", "m3", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(70d, 14 * fit.Convert(5.00), 9);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_heating_oil_price_per_100_litres_is_charged_per_litre()
|
|
{
|
|
// 98.50 €/100 L on the seeded Öltank (L): 1,000 L cost 985 €, not 98,500 €.
|
|
var fit = TariffUnit.Applicability("EUR/100L", "L", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(985d, 1000 * fit.Convert(98.50), 9);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_feed_in_price_in_cents_is_converted_like_a_unit_price()
|
|
{
|
|
var fit = TariffUnit.Applicability("ct/kWh", "kWh", TariffComponent.FeedIn);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(0.082, fit.Convert(8.2), 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_energy_price_does_not_price_water()
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/kWh", "m³", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.False(fit.Applies);
|
|
Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue);
|
|
Assert.Equal("kWh", fit.TariffDenominator);
|
|
Assert.Equal("m³", fit.MeterUnit);
|
|
Assert.Equal("EUR/kWh", fit.TariffUnit);
|
|
Assert.Equal(0d, fit.Convert(0.30));
|
|
}
|
|
|
|
[Theory]
|
|
// A global electricity price used to price water and burner hours too (costing review, §10.2).
|
|
[InlineData("EUR/kWh", "h")]
|
|
[InlineData("EUR/100L", "h")]
|
|
[InlineData("EUR/h", "L")]
|
|
[InlineData("EUR/kWh", "kW")]
|
|
[InlineData("EUR/L", "L/min")]
|
|
[InlineData("EUR/m³", "m³/h")]
|
|
public void A_price_in_another_dimension_is_a_mismatch(string tariffUnit, string meterUnit)
|
|
{
|
|
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.IncompatibleUnit, fit.Issue);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", "Stk", 1)]
|
|
[InlineData("EUR/kWh", "kWh (el)", 1)]
|
|
[InlineData("EUR/m³", "Nm³", 1)]
|
|
[InlineData("ct/kWh", "Einheiten", 0.01)]
|
|
[InlineData("EUR/kWh", "mwh", 1)]
|
|
public void A_meter_unit_the_module_does_not_recognise_is_unverified_not_a_mismatch(
|
|
string tariffUnit, string meterUnit, double factor)
|
|
{
|
|
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.True(fit.Applies);
|
|
Assert.True(fit.NeedsWarning);
|
|
Assert.Equal(TariffUnitIssue.MeterUnitUnrecognised, fit.Issue);
|
|
Assert.Equal(factor, fit.Factor, 12);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", "Kilowattstunden", 1)]
|
|
[InlineData("EUR/kWh", "kilowatt-hours", 1)]
|
|
[InlineData("EUR/MWh", "Kilowattstunde", 0.001)]
|
|
[InlineData("ct/kWh", "Wattstunden", 0.00001)]
|
|
[InlineData("EUR/Megawattstunde", "kWh", 0.001)]
|
|
public void Long_form_and_German_energy_units_are_priced_like_their_symbols(string tariffUnit, string meterUnit, double factor)
|
|
{
|
|
var fit = TariffUnit.Applicability(tariffUnit, meterUnit, TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(factor, fit.Factor, 12);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("mWh", 0.000_001)]
|
|
[InlineData("MWh", 1000)]
|
|
[InlineData("MWH", 1000)]
|
|
public void A_milli_unit_is_never_priced_as_a_mega_unit(string meterUnit, double factor)
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/kWh", meterUnit, TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(factor, fit.Factor, 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_standing_charge_unit_on_a_unit_price_is_a_mismatch()
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/month", "kWh", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue);
|
|
Assert.Equal("month", fit.TariffDenominator);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unparseable_unit_price_applies_at_face_value_with_a_warning()
|
|
{
|
|
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.True(fit.Applies);
|
|
Assert.True(fit.NeedsWarning);
|
|
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
|
|
Assert.Equal(1d, fit.Factor);
|
|
Assert.Equal("pauschal", fit.TariffDenominator);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unparseable_unit_in_cents_still_converts_the_currency()
|
|
{
|
|
var fit = TariffUnit.Applicability("ct", "kWh", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.Equal(0.01, fit.Factor);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unknown_denominator_applies_to_a_meter_in_that_unit()
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/Stk", "stk", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(1d, fit.Factor);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unknown_denominator_on_another_meter_applies_with_a_warning()
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/Stk", "kWh", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.UnknownDenominator, fit.Issue);
|
|
Assert.Equal(1d, fit.Factor);
|
|
}
|
|
|
|
// ---- Qualifiers ---------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("€/kWh brutto", TariffUnitBasis.Quantity, "kWh", "brutto")]
|
|
[InlineData("EUR/kWh netto", TariffUnitBasis.Quantity, "kWh", "netto")]
|
|
[InlineData("EUR/kWh zzgl. 19 % MwSt.", TariffUnitBasis.Quantity, "kWh", "zzgl. 19 % MwSt.")]
|
|
[InlineData("ct/kWh, inkl. USt", TariffUnitBasis.Quantity, "kWh", "inkl. USt")]
|
|
[InlineData("EUR/kWh (Grundversorgung)", TariffUnitBasis.Quantity, "kWh", "Grundversorgung")]
|
|
[InlineData("EUR/100 L [Heizöl EL] incl. VAT", TariffUnitBasis.Quantity, "100 L", "incl. VAT Heizöl EL")]
|
|
[InlineData("€/Jahr inkl. MwSt.", TariffUnitBasis.Period, "year", "inkl. MwSt.")]
|
|
[InlineData("EUR/Monat (netto)", TariffUnitBasis.Period, "month", "netto")]
|
|
[InlineData("EUR per kWh gross", TariffUnitBasis.Quantity, "kWh", "gross")]
|
|
public void VAT_notes_and_bracketed_text_are_set_aside_before_the_unit_is_read(
|
|
string unit, TariffUnitBasis basis, string denominator, string qualifier)
|
|
{
|
|
var parsed = TariffUnit.Parse(unit);
|
|
|
|
Assert.Equal(basis, parsed.Basis);
|
|
Assert.Equal(denominator, parsed.DenominatorText);
|
|
Assert.Equal(qualifier, parsed.Qualifier);
|
|
Assert.Equal(unit, parsed.Raw);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_unit_without_a_note_has_no_qualifier()
|
|
{
|
|
Assert.Null(TariffUnit.Parse("EUR/kWh").Qualifier);
|
|
Assert.Null(TariffUnit.Parse("pauschal").Qualifier);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_price_with_a_VAT_note_applies_cleanly()
|
|
{
|
|
var fit = TariffUnit.Applicability("€/kWh brutto", "kWh", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(1d, fit.Factor);
|
|
Assert.Equal("€/kWh brutto", fit.TariffUnit);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
public void A_meter_without_a_unit_cannot_be_checked(string? meterUnit)
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/100L", meterUnit, TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.MeterUnitUnknown, fit.Issue);
|
|
Assert.Equal(0.01, fit.Factor);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(TariffComponent.Bonus)]
|
|
[InlineData(TariffComponent.Discount)]
|
|
[InlineData(TariffComponent.Tax)]
|
|
public void Bonus_discount_and_tax_are_not_applied(TariffComponent component)
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/kWh", "kWh", component);
|
|
|
|
Assert.Equal(TariffUnitFit.NotApplied, fit.Fit);
|
|
Assert.False(fit.Applies);
|
|
Assert.Equal(TariffUnitIssue.ComponentNotApplied, fit.Issue);
|
|
Assert.Equal(0d, fit.Convert(12));
|
|
}
|
|
|
|
// ---- Standing charges ---------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("EUR/month", BillingPeriod.Month)]
|
|
[InlineData("EUR/Jahr", BillingPeriod.Year)]
|
|
[InlineData("EUR/Tag", BillingPeriod.Day)]
|
|
public void A_standing_charge_applies_per_its_period_whatever_the_meter_unit(string unit, BillingPeriod period)
|
|
{
|
|
var fit = TariffUnit.Applicability(unit, "m³", TariffComponent.BasePrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(period, fit.Period);
|
|
Assert.Equal(1d, fit.Factor);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_monthly_charge_accrues_over_the_days_of_each_local_month()
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual("EUR/month");
|
|
|
|
Assert.Equal(BillingPeriod.Month, accrual.Period);
|
|
Assert.False(accrual.Assumed);
|
|
Assert.Equal(12d / 28, accrual.PerDay(12, new DateOnly(2026, 2, 10)), 12);
|
|
Assert.Equal(12d / 29, accrual.PerDay(12, new DateOnly(2024, 2, 10)), 12);
|
|
Assert.Equal(12d / 31, accrual.PerDay(12, new DateOnly(2026, 1, 31)), 12);
|
|
Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2026, 2, 1), new DateOnly(2026, 3, 1)), 9);
|
|
Assert.Equal(12d, SumOverDays(accrual, 12, new DateOnly(2024, 2, 1), new DateOnly(2024, 3, 1)), 9);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_yearly_charge_accrues_over_the_days_of_the_local_year()
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual("EUR/Jahr");
|
|
|
|
Assert.Equal(BillingPeriod.Year, accrual.Period);
|
|
Assert.Equal(120d / 366, accrual.PerDay(120, new DateOnly(2024, 7, 1)), 12);
|
|
Assert.Equal(120d / 365, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12);
|
|
Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2024, 1, 1), new DateOnly(2025, 1, 1)), 9);
|
|
Assert.Equal(120d, SumOverDays(accrual, 120, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_daily_charge_is_charged_as_is_and_a_charge_in_cents_is_converted()
|
|
{
|
|
Assert.Equal(0.5, TariffUnit.BaseAccrual("EUR/Tag").PerDay(0.5, new DateOnly(2026, 2, 1)), 12);
|
|
Assert.Equal(0.5, TariffUnit.BaseAccrual("ct/Tag").PerDay(50, new DateOnly(2026, 2, 1)), 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_accrual_takes_the_month_length_directly()
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual("EUR/Monat");
|
|
|
|
Assert.Equal(1d, accrual.PerDay(30, daysInMonth: 30, daysInYear: 365), 12);
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => accrual.PerDay(30, daysInMonth: 0, daysInYear: 365));
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unparseable_standing_charge_is_taken_per_month_with_a_warning()
|
|
{
|
|
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.BasePrice);
|
|
var accrual = TariffUnit.BaseAccrual("pauschal");
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.True(fit.NeedsWarning);
|
|
Assert.Equal(BillingPeriod.Month, fit.Period);
|
|
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
|
|
Assert.Equal(BillingPeriod.Month, accrual.Period);
|
|
Assert.True(accrual.Assumed);
|
|
Assert.Equal(10d / 30, accrual.PerDay(10, new DateOnly(2026, 4, 15)), 12);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("€/Jahr inkl. MwSt.", 120d / 365)]
|
|
[InlineData("EUR/Jahr brutto", 120d / 365)]
|
|
[InlineData("EUR/12 Monate", 120d / 365)]
|
|
[InlineData("EUR/Quartal", 120d / 92)]
|
|
public void A_standing_charge_is_never_taken_per_month_when_its_period_can_be_read(string unit, double perDay)
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual(unit);
|
|
|
|
Assert.False(accrual.Assumed);
|
|
Assert.Equal(TariffUnitFit.Applies, accrual.Fit);
|
|
Assert.Equal(perDay, accrual.PerDay(120, new DateOnly(2026, 7, 1)), 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_quarterly_charge_accrues_over_the_days_of_its_local_calendar_quarter()
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual("EUR/Quartal");
|
|
|
|
Assert.Equal(BillingPeriod.Quarter, accrual.Period);
|
|
Assert.Equal(90, accrual.DaysInPeriod(new DateOnly(2026, 2, 14)));
|
|
Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2024, 3, 31)));
|
|
Assert.Equal(91, accrual.DaysInPeriod(new DateOnly(2026, 4, 1)));
|
|
Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 8, 31)));
|
|
Assert.Equal(92, accrual.DaysInPeriod(new DateOnly(2026, 12, 31)));
|
|
Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2024, 1, 1), new DateOnly(2024, 4, 1)), 9);
|
|
Assert.Equal(30d, SumOverDays(accrual, 30, new DateOnly(2026, 7, 1), new DateOnly(2026, 10, 1)), 9);
|
|
Assert.Equal(120d, SumOverDays(accrual, 30, new DateOnly(2026, 1, 1), new DateOnly(2027, 1, 1)), 9);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_quarterly_charge_cannot_be_spread_by_month_and_year_lengths_alone()
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual("EUR/Quartal");
|
|
|
|
Assert.Throws<InvalidOperationException>(() => accrual.PerDay(30, daysInMonth: 31, daysInYear: 365));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/2 Monate", "2 Monate")]
|
|
[InlineData("EUR/Woche", "Woche")]
|
|
[InlineData("EUR/Halbjahr", "Halbjahr")]
|
|
[InlineData("EUR/7 Tage", "7 Tage")]
|
|
[InlineData("EUR halbjährlich", "halbjährlich")]
|
|
public void A_period_that_is_read_but_cannot_be_accrued_is_a_mismatch_not_a_month(string unit, string written)
|
|
{
|
|
var parsed = TariffUnit.Parse(unit);
|
|
var fit = TariffUnit.Applicability(unit, "kWh", TariffComponent.BasePrice);
|
|
var accrual = TariffUnit.BaseAccrual(unit);
|
|
|
|
Assert.Equal(TariffUnitBasis.UnsupportedPeriod, parsed.Basis);
|
|
Assert.True(parsed.IsRecognised);
|
|
Assert.Equal(written, parsed.DenominatorText);
|
|
Assert.False(parsed.Suits(TariffComponent.BasePrice));
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.UnsupportedPeriod, fit.Issue);
|
|
Assert.Equal(0d, fit.Factor);
|
|
Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit);
|
|
Assert.False(accrual.Applies);
|
|
Assert.False(accrual.Assumed);
|
|
Assert.Equal(0d, accrual.PerDay(24, new DateOnly(2026, 5, 1)));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_period_on_a_unit_price_is_a_mismatch_even_when_it_cannot_be_accrued()
|
|
{
|
|
var fit = TariffUnit.Applicability("EUR/2 Monate", "kWh", TariffComponent.UnitPrice);
|
|
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.PeriodForQuantity, fit.Issue);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_standing_charge_quoted_per_quantity_is_taken_per_month_with_a_warning()
|
|
{
|
|
var accrual = TariffUnit.BaseAccrual("EUR/kWh");
|
|
|
|
Assert.True(accrual.Assumed);
|
|
Assert.Equal(BillingPeriod.Month, accrual.Period);
|
|
Assert.Equal(TariffUnitIssue.QuantityForPeriod, accrual.Issue);
|
|
}
|
|
|
|
// ---- Currency -----------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("USD/kWh", "EUR")]
|
|
[InlineData("SEK/kWh", "EUR")]
|
|
[InlineData("£/kWh", "EUR")]
|
|
[InlineData("p/kWh", "EUR")]
|
|
[InlineData("Rp./kWh", "EUR")]
|
|
[InlineData("ct/kWh", "GBP")]
|
|
[InlineData("EUR/kWh", "CHF")]
|
|
[InlineData("USD", "EUR")]
|
|
public void A_price_in_another_currency_does_not_price_the_instance_currency(string tariffUnit, string currency)
|
|
{
|
|
var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency);
|
|
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue);
|
|
Assert.Equal(0d, fit.Convert(0.30));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", "EUR", 1)]
|
|
[InlineData("€/kWh", "eur", 1)]
|
|
[InlineData("EUR/kWh", "€", 1)]
|
|
[InlineData("ct/kWh", "EUR", 0.01)]
|
|
[InlineData("Cent/kWh", "USD", 0.01)]
|
|
[InlineData("p/kWh", "GBP", 0.01)]
|
|
[InlineData("Rp./kWh", "CHF", 0.01)]
|
|
[InlineData("Fr./kWh", "CHF", 1)]
|
|
[InlineData("SEK/kWh", "sek", 1)]
|
|
public void A_minor_unit_applies_only_under_its_own_major_currency(string tariffUnit, string currency, double factor)
|
|
{
|
|
var fit = TariffUnit.Applicability(tariffUnit, "kWh", TariffComponent.UnitPrice, currency);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, fit.Fit);
|
|
Assert.Equal(factor, fit.Factor, 12);
|
|
}
|
|
|
|
[Fact]
|
|
public void Without_an_expected_currency_the_currency_is_not_checked()
|
|
{
|
|
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.UnitPrice).Fit);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_unit_without_a_currency_cannot_be_checked_against_one()
|
|
{
|
|
var fit = TariffUnit.Applicability("pauschal", "kWh", TariffComponent.UnitPrice, "EUR");
|
|
|
|
Assert.Equal(TariffUnitFit.Unverified, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.Unparseable, fit.Issue);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_standing_charge_in_another_currency_accrues_nothing()
|
|
{
|
|
var fit = TariffUnit.Applicability("USD/month", "kWh", TariffComponent.BasePrice, "EUR");
|
|
var accrual = TariffUnit.BaseAccrual("USD/month", "EUR");
|
|
|
|
Assert.Equal(TariffUnitFit.Mismatch, fit.Fit);
|
|
Assert.Equal(TariffUnitIssue.CurrencyMismatch, fit.Issue);
|
|
Assert.Equal(BillingPeriod.Month, fit.Period);
|
|
Assert.Equal(TariffUnitFit.Mismatch, accrual.Fit);
|
|
Assert.Equal(0d, accrual.PerDay(12, new DateOnly(2026, 2, 1)));
|
|
Assert.Equal(TariffUnitFit.Applies, TariffUnit.BaseAccrual("EUR/month", "EUR").Fit);
|
|
}
|
|
|
|
[Fact]
|
|
public void Bonus_discount_and_tax_stay_not_applied_whatever_their_currency()
|
|
{
|
|
Assert.Equal(TariffUnitFit.NotApplied, TariffUnit.Applicability("USD/kWh", "kWh", TariffComponent.Tax, "EUR").Fit);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_expected_currency_must_be_named()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => TariffUnit.Applicability("EUR/kWh", "kWh", TariffComponent.UnitPrice, " "));
|
|
Assert.Throws<ArgumentException>(() => TariffUnit.BaseAccrual("EUR/month", ""));
|
|
}
|
|
|
|
// ---- Parsed once --------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("EUR/kWh", "MWh", TariffComponent.UnitPrice)]
|
|
[InlineData("EUR/100L", "L", TariffComponent.UnitPrice)]
|
|
[InlineData("pauschal", "kWh", TariffComponent.UnitPrice)]
|
|
[InlineData("EUR/Jahr", "m³", TariffComponent.BasePrice)]
|
|
[InlineData("USD/kWh", "kWh", TariffComponent.FeedIn)]
|
|
[InlineData("EUR/kWh", "kWh", TariffComponent.Tax)]
|
|
public void A_unit_parsed_once_is_checked_exactly_like_its_text(string tariffUnit, string meterUnit, TariffComponent component)
|
|
{
|
|
var parsed = TariffUnit.Parse(tariffUnit);
|
|
|
|
Assert.Equal(
|
|
TariffUnit.Applicability(tariffUnit, meterUnit, component),
|
|
TariffUnit.Applicability(parsed, meterUnit, component));
|
|
Assert.Equal(
|
|
TariffUnit.Applicability(tariffUnit, meterUnit, component, "EUR"),
|
|
TariffUnit.Applicability(parsed, meterUnit, component, "EUR"));
|
|
Assert.Equal(TariffUnit.BaseAccrual(tariffUnit), TariffUnit.BaseAccrual(parsed));
|
|
Assert.Equal(TariffUnit.BaseAccrual(tariffUnit, "EUR"), TariffUnit.BaseAccrual(parsed, "EUR"));
|
|
}
|
|
|
|
// ---- Alias tables -------------------------------------------------------------------------------
|
|
|
|
public static TheoryData<string, string, double, string> CurrencyAliases
|
|
{
|
|
get
|
|
{
|
|
var data = new TheoryData<string, string, double, string>();
|
|
foreach (var entry in TariffUnit.CurrencyAliasTable)
|
|
{
|
|
data.Add(entry.Alias, entry.Code, entry.Scale, entry.Majors[0]);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
}
|
|
|
|
public static TheoryData<string> PeriodAliases
|
|
{
|
|
get
|
|
{
|
|
var data = new TheoryData<string>();
|
|
foreach (var entry in TariffUnit.PeriodAliasTable)
|
|
{
|
|
data.Add(entry.Alias);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(CurrencyAliases))]
|
|
public void Every_currency_spelling_parses_to_its_code_and_scale_and_prices_its_major_currency(
|
|
string alias, string code, double scale, string major)
|
|
{
|
|
var parsed = TariffUnit.Parse($"{alias}/kWh");
|
|
|
|
Assert.Equal(TariffUnitBasis.Quantity, parsed.Basis);
|
|
Assert.Equal(code, parsed.Currency);
|
|
Assert.Equal(scale, parsed.CurrencyScale);
|
|
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(parsed, "kWh", TariffComponent.UnitPrice, major).Fit);
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(PeriodAliases))]
|
|
public void Every_period_spelling_is_read_as_a_period(string alias)
|
|
{
|
|
var slashed = TariffUnit.Parse($"EUR/{alias}");
|
|
var spaced = TariffUnit.Parse($"EUR pro {alias}");
|
|
|
|
Assert.Contains(slashed.Basis, new[] { TariffUnitBasis.Period, TariffUnitBasis.UnsupportedPeriod });
|
|
Assert.Equal(slashed.Basis, spaced.Basis);
|
|
Assert.Equal(slashed.Period, spaced.Period);
|
|
if (slashed.Basis == TariffUnitBasis.Period)
|
|
{
|
|
// A supported period round-trips through the token it is written with.
|
|
Assert.Equal(slashed.Period, TariffUnit.Parse($"EUR/{TariffUnit.PeriodToken(slashed.Period!.Value)}").Period);
|
|
}
|
|
}
|
|
|
|
// ---- Suggestions --------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData(TariffComponent.UnitPrice, "m3", "EUR/m³")]
|
|
[InlineData(TariffComponent.FeedIn, "kWh", "EUR/kWh")]
|
|
[InlineData(TariffComponent.BasePrice, "kWh", "EUR/month")]
|
|
[InlineData(TariffComponent.UnitPrice, "", "EUR")]
|
|
[InlineData(TariffComponent.Tax, "kWh", "EUR")]
|
|
public void The_editor_suggests_a_unit_for_the_component(TariffComponent component, string meterUnit, string expected)
|
|
{
|
|
Assert.Equal(expected, TariffUnit.Suggest(component, meterUnit));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(TariffComponent.UnitPrice, "m3")]
|
|
[InlineData(TariffComponent.FeedIn, "kWh")]
|
|
[InlineData(TariffComponent.UnitPrice, "L")]
|
|
[InlineData(TariffComponent.BasePrice, "kWh")]
|
|
public void A_suggested_unit_applies_cleanly_to_the_meter_it_was_suggested_for(TariffComponent component, string meterUnit)
|
|
{
|
|
var suggested = TariffUnit.Suggest(component, meterUnit);
|
|
|
|
Assert.Equal(TariffUnitFit.Applies, TariffUnit.Applicability(suggested, meterUnit, component).Fit);
|
|
Assert.True(TariffUnit.Parse(suggested).Suits(component));
|
|
}
|
|
|
|
private static double SumOverDays(BasePriceAccrual accrual, double value, DateOnly from, DateOnly to)
|
|
{
|
|
var total = 0d;
|
|
for (var day = from; day < to; day = day.AddDays(1))
|
|
{
|
|
total += accrual.PerDay(value, day);
|
|
}
|
|
|
|
return total;
|
|
}
|
|
}
|