Files
MeterVault/tests/Integration.Tests/Analysis/AnalysisUiTestData.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
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.
2026-09-20 12:44:32 +02:00

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