ci / build-test (push) Successful in 2m41s
Three things a reported Heizoel page got wrong at once. Its tank is dipped a few times a year and its burner read every few months, which is exactly the shape the coverage rules had not been walked through. "No data" for data that exists. A tank books nothing until the next dipstick closes the interval, so the stretch after the last dipstick is covered by no run at all, and a bucket no run covers was reported missing. The burner, whose run reaches into the window, said "only coarser data" -- the honest answer -- so one card claimed there was nothing while the coverage panel beside it listed years of data. A bucket that no run covers, no gap overlaps and no opening balance explains now reports the meter's resolution when its preceding coverage is within one interval of its own class: it is not silent, it is read rarely. A meter that does book its own buckets and stops -- a dead hourly source, a sheet asked about a later month -- still reads missing. Auto answering twelve months with one bar. Coarse only means "longer than a month", so a dipstick taken each autumn straddles a New Year as surely as a month start: coarsening the chart to years bought nothing and cost every point. The planning resolution now caps coarse at month when a run crosses a local year edge, and a series that cannot resolve the natural size no longer coarsens the whole chart -- it is drawn at that size with its buckets marked, which the chart and table already explain. A page contradicting itself. The comparison line above the ranking was fed the leading measure's matched coverage but worded as if it spoke for the page, directly above a burner row that did compare. It now names the figure it is about. Alongside: the "largest changes" ranking no longer drops a meter whose change is not comparable. It ranks what can be ranked, then lists the rest with their values and the reason -- the tank had been vanishing from its own energy type. And every dated table now reads newest first, as lists are read; charts stay chronological left to right, and the CSV export stays ascending for spreadsheets. A-41 to A-43 in the note record the three rules.
105 lines
4.6 KiB
C#
105 lines
4.6 KiB
C#
using System.Globalization;
|
|
using MeterVault.Core.Analysis;
|
|
using MeterVault.Core.Analysis.Costing;
|
|
using MeterVault.Core.Analysis.Coverage;
|
|
using MeterVault.Core.Analysis.Totals;
|
|
using MeterVault.Core.Domain;
|
|
using MeterVault.Infrastructure.Analysis;
|
|
|
|
namespace MeterVault.Integration.Tests.Analysis;
|
|
|
|
/// <summary>
|
|
/// Builders for the pure tests of the shared analysis components: real buckets from the period resolver and the bucket
|
|
/// planner (Berlin, frozen clock), values, reader series, priced cost figures, and a culture scope — the machine running
|
|
/// the tests may be German, and the components format for the reader.
|
|
/// </summary>
|
|
internal static class AnalysisUiTestData
|
|
{
|
|
public static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
|
|
|
/// <summary>Long after every range the tests use, so custom ranges resolve as complete.</summary>
|
|
public static readonly DateTimeOffset FarFuture = new(2040, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
/// <summary>19 September 2026, 14:37 Berlin.</summary>
|
|
public static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 19, 14, 37, 0, TimeSpan.FromHours(2)).ToUniversalTime();
|
|
|
|
public static DateOnly D(int year, int month, int day) => new(year, month, day);
|
|
|
|
public static ResolvedPeriod Range(DateOnly first, DateOnly last) =>
|
|
PeriodResolver.Resolve(PeriodPreset.Custom, first, last, FarFuture, Berlin);
|
|
|
|
public static IReadOnlyList<AnalysisBucket> Buckets(DateOnly first, DateOnly last, BucketSize size = BucketSize.Month) =>
|
|
BucketPlanner.Plan(Range(first, last), size).Buckets;
|
|
|
|
public static BucketValue Available(double value, Provenance provenance = Provenance.Measured) => BucketValue.Available(value, provenance);
|
|
|
|
public static BucketValue Partial(double value) =>
|
|
new(value, BucketStatus.Partial, Provenance.Measured, ValueIssue.PartialCoverage);
|
|
|
|
public static BucketValue Missing() => BucketValue.Missing();
|
|
|
|
/// <summary>A physical meter's series in kWh (or <paramref name="unit"/>) with its total and, optionally, a comparison.</summary>
|
|
public static AnalysisSeries Series(
|
|
int meterId,
|
|
string name,
|
|
IReadOnlyList<BucketValue> values,
|
|
BucketValue? total = null,
|
|
QuantityKind kind = QuantityKind.Consumption,
|
|
SeriesComparison? comparison = null,
|
|
string unit = "kWh") =>
|
|
new(SeriesKey.ForMeter(meterId, 1, unit), name, SeriesBasis.Physical, kind, unit, values, total ?? Available(values.Sum(v => v.Value ?? 0)), IsAdditive: true)
|
|
{
|
|
Comparison = comparison,
|
|
};
|
|
|
|
public static SeriesComparison Comparison(IReadOnlyList<BucketValue> values, BucketValue total, Change change) =>
|
|
new(values, total, MatchedCoverageResult.NotComparable, null, null, change);
|
|
|
|
/// <summary>
|
|
/// One energy type's bill line priced over <paramref name="buckets"/> at <paramref name="price"/> €/kWh from
|
|
/// <paramref name="priceFrom"/> (no tariff at all when null), with <paramref name="amount"/> kWh per month.
|
|
/// </summary>
|
|
public static CostResult Priced(IReadOnlyList<AnalysisBucket> buckets, double amount, double? price, DateOnly? priceFrom = null)
|
|
{
|
|
Tariff[] tariffs = price is { } value
|
|
? [new Tariff
|
|
{
|
|
Id = 1,
|
|
ScopeType = TariffScope.EnergyType,
|
|
ScopeId = 1,
|
|
Component = TariffComponent.UnitPrice,
|
|
Value = value,
|
|
Unit = "EUR/kWh",
|
|
ValidFrom = priceFrom ?? D(2000, 1, 1),
|
|
}]
|
|
: [];
|
|
var quantities = CostCalculator.Parts(buckets).Select(p => CostQuantity.Known(p, amount)).ToList();
|
|
var line = new CostLine(10, 1, BillLineKind.UnitPrice, "kWh", quantities);
|
|
return CostCalculator.Calculate(new CostRequest(buckets, D(2039, 12, 31), TariffBook.Create(tariffs, "EUR"), [line]));
|
|
}
|
|
|
|
/// <summary>Runs <paramref name="body"/> with <paramref name="culture"/> as the formatting and the UI culture.</summary>
|
|
public static T In<T>(string culture, Func<T> body)
|
|
{
|
|
var format = CultureInfo.CurrentCulture;
|
|
var ui = CultureInfo.CurrentUICulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
|
|
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture);
|
|
return body();
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = format;
|
|
CultureInfo.CurrentUICulture = ui;
|
|
}
|
|
}
|
|
|
|
public static void In(string culture, Action body) => In(culture, () =>
|
|
{
|
|
body();
|
|
return 0;
|
|
});
|
|
}
|