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.
263 lines
14 KiB
C#
263 lines
14 KiB
C#
using MeterVault.App;
|
|
using MeterVault.App.Analysis;
|
|
using MeterVault.Core.Analysis;
|
|
using MeterVault.Core.Analysis.Costing;
|
|
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
|
|
|
|
namespace MeterVault.Integration.Tests.Analysis;
|
|
|
|
/// <summary>
|
|
/// The analysis table (brief §7.2) and the words beside every figure (D-08, D-14, brief §4.3): unknown is "—" and zero
|
|
/// is 0, the status is spoken, a change is stated only between complete figures and always with its absolute
|
|
/// difference, polarity decides the tone, cost columns name their price coverage. Pure; no database.
|
|
/// </summary>
|
|
public sealed class AnalysisTableModelTests
|
|
{
|
|
[Fact]
|
|
public void One_row_per_bucket_and_a_total_row_with_the_status_in_words() => In("en", () =>
|
|
{
|
|
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
|
|
var series = AnalysisTableSeries.ForSeries(Series(1, "Haus", [Available(120), Missing(), Available(0)], total: Partial(120)));
|
|
|
|
var table = AnalysisTableModel.Build(buckets, [series]);
|
|
|
|
Assert.Equal([AnalysisTableColumnKind.Value, AnalysisTableColumnKind.Status], table.Columns.Select(c => c.Kind));
|
|
Assert.Equal("Haus", table.Columns[0].Header);
|
|
// A-42: the total first, then the buckets newest first.
|
|
Assert.Equal(["Total", "Mar", "Feb", "Jan"], table.Rows.Select(r => r.Label));
|
|
Assert.Equal(buckets[1], table.Rows[2].Bucket);
|
|
Assert.Null(table.Rows[0].Bucket);
|
|
Assert.True(table.Rows[0].IsTotal);
|
|
|
|
Assert.Equal(["120 kWh", "0 kWh", "—", "120 kWh"], table.Rows.Select(r => r.Cells[0].Text));
|
|
Assert.True(table.Rows[2].Cells[0].IsUnknown);
|
|
Assert.False(table.Rows[1].Cells[0].IsUnknown);
|
|
Assert.Equal("Complete · Measured", table.Rows[3].Cells[1].Text);
|
|
Assert.Equal("No data", table.Rows[2].Cells[1].Text);
|
|
Assert.Equal("No data covers this period", table.Rows[2].Cells[1].Secondary);
|
|
Assert.Equal("Partial · Measured", table.Rows[0].Cells[1].Text);
|
|
Assert.Equal([true, false, true, false], table.Rows.Select(r => r.IsQualified));
|
|
});
|
|
|
|
[Fact]
|
|
public void Every_dated_table_reads_newest_first_with_the_total_above_the_rows() => In("en", () =>
|
|
{
|
|
// A-42: a table is read from the top, so the latest period is the first row; the plan itself stays
|
|
// chronological, and each row keeps its own bucket for the drill-down.
|
|
var buckets = Buckets(D(2025, 11, 1), D(2026, 2, 28));
|
|
var series = AnalysisTableSeries.ForSeries(
|
|
Series(1, "Öltank", [Available(10), Available(20), Available(30), Available(40)], total: Available(100)));
|
|
|
|
var table = AnalysisTableModel.Build(buckets, [series]);
|
|
|
|
Assert.Equal(["Total", "Feb 2026", "Jan 2026", "Dec 2025", "Nov 2025"], table.Rows.Select(r => r.Label));
|
|
Assert.True(table.Rows[0].IsTotal);
|
|
Assert.All(table.Rows.Skip(1), r => Assert.False(r.IsTotal));
|
|
Assert.Equal(["100 kWh", "40 kWh", "30 kWh", "20 kWh", "10 kWh"], table.Rows.Select(r => r.Cells[0].Text));
|
|
|
|
// The drill-down of a row is its own bucket, not the one at that index in the plan.
|
|
Assert.Equal([buckets[3], buckets[2], buckets[1], buckets[0]], table.Rows.Skip(1).Select(r => r.Bucket));
|
|
Assert.Equal(D(2025, 11, 1), buckets[0].FirstDay);
|
|
|
|
// Without a total row the newest bucket is simply the first row.
|
|
var plain = AnalysisTableModel.Build(buckets, [series], includeTotal: false);
|
|
Assert.Equal(["Feb 2026", "Jan 2026", "Dec 2025", "Nov 2025"], plain.Rows.Select(r => r.Label));
|
|
Assert.Empty(AnalysisTableModel.Build([], [series]).Rows);
|
|
});
|
|
|
|
[Fact]
|
|
public void A_change_is_stated_only_between_complete_figures_and_names_the_compared_bucket() => In("en", () =>
|
|
{
|
|
var period = Range(D(2026, 1, 1), D(2026, 3, 31));
|
|
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
|
|
var pairs = ComparisonResolver.PairBuckets(
|
|
period, ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear)).Period!, buckets);
|
|
var matched = Change.Between(200, 190);
|
|
var reader = Series(
|
|
1, "Haus", [Available(120), Partial(50), Available(80)], total: Available(250),
|
|
comparison: Comparison([Available(100), Available(90), Missing()], Available(190), matched));
|
|
|
|
var table = AnalysisTableModel.Build(buckets, [AnalysisTableSeries.ForSeries(reader)], pairs);
|
|
|
|
Assert.Equal(
|
|
[AnalysisTableColumnKind.Value, AnalysisTableColumnKind.Status, AnalysisTableColumnKind.Comparison, AnalysisTableColumnKind.Change],
|
|
table.Columns.Select(c => c.Kind));
|
|
var comparison = table.Rows.Select(r => r.Cells[2]).ToList();
|
|
var change = table.Rows.Select(r => r.Cells[3]).ToList();
|
|
|
|
// Newest first (A-42), so the paired bucket of each row still names the month it is compared with.
|
|
Assert.Equal(["190 kWh", "—", "90 kWh", "100 kWh"], comparison.Select(c => c.Text));
|
|
Assert.Equal([null, "Mar 2025", "Feb 2025", "Jan 2025"], comparison.Select(c => c.Secondary));
|
|
|
|
// January: both complete. February: the current month is partial. March: nothing to compare with.
|
|
Assert.Equal("+20 kWh (+20.0 %)", change[3].Text);
|
|
Assert.Equal("mv-change-bad", change[3].CssClass);
|
|
Assert.Equal("—", change[2].Text);
|
|
Assert.True(change[2].IsUnknown);
|
|
Assert.Equal("—", change[1].Text);
|
|
|
|
// The total row states the reader's change over the matched coverage, not a sum of the rows.
|
|
Assert.Equal("+10 kWh (+5.3 %)", change[0].Text);
|
|
});
|
|
|
|
[Fact]
|
|
public void More_generation_is_good_news_and_a_net_result_is_neutral() => In("en", () =>
|
|
{
|
|
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
|
|
var cmp = Comparison([Available(100)], Available(100), Change.Unavailable);
|
|
|
|
var generation = Series(1, "Solar", [Available(150)], kind: QuantityKind.Generation, comparison: cmp);
|
|
var net = Series(2, "Bilanz", [Available(150)], kind: QuantityKind.Net, comparison: cmp);
|
|
var table = AnalysisTableModel.Build(buckets, [AnalysisTableSeries.ForSeries(generation), AnalysisTableSeries.ForSeries(net)]);
|
|
|
|
Assert.True(table.Rows[0].IsTotal);
|
|
Assert.Equal("mv-change-good", table.Rows[1].Cells[3].CssClass);
|
|
Assert.Equal("mv-change-neutral", table.Rows[1].Cells[7].CssClass);
|
|
|
|
// With two series, every column but the value names its series.
|
|
Assert.Equal("Solar", table.Columns[1].SubHeader);
|
|
Assert.Equal("Bilanz", table.Columns[4].Header);
|
|
});
|
|
|
|
[Fact]
|
|
public void Cost_columns_name_their_price_coverage() => In("en", () =>
|
|
{
|
|
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
|
|
var priced = Priced(buckets, 100, 0.25, priceFrom: D(2026, 2, 1)).Lines[0];
|
|
var series = AnalysisTableSeries.ForValues("m10", "Netz", "kWh", [Available(100), Available(100), Available(100)], Available(300))
|
|
.WithCosts(priced.Buckets, priced.Total, "EUR");
|
|
|
|
var table = AnalysisTableModel.Build(buckets, [series]);
|
|
|
|
Assert.Equal(AnalysisTableColumnKind.Cost, table.Columns[2].Kind);
|
|
Assert.Equal(["50.00 €", "25.00 €", "25.00 €", "—"], table.Rows.Select(r => r.Cells[2].Text));
|
|
Assert.Equal("Unavailable (tariff gap)", table.Rows[3].Cells[3].Text);
|
|
Assert.Equal("Priced", table.Rows[2].Cells[3].Text);
|
|
Assert.Equal("Partly priced", table.Rows[0].Cells[3].Text);
|
|
});
|
|
|
|
[Fact]
|
|
public void A_cost_series_compares_money_and_a_credit_is_neutral() => In("en", () =>
|
|
{
|
|
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
|
|
var current = Priced(buckets, 100, 0.25).Lines[0];
|
|
var previous = Priced(buckets, 80, 0.25).Lines[0];
|
|
|
|
var table = AnalysisTableModel.Build(
|
|
buckets,
|
|
[AnalysisTableSeries.ForCosts("cost", "Strom", "EUR", current.Buckets, current.Total).WithComparisonCosts(previous.Buckets, previous.Total, "EUR")]);
|
|
|
|
Assert.Equal(["50.00 €", "25.00 €", "25.00 €"], table.Rows.Select(r => r.Cells[0].Text));
|
|
Assert.Equal("+5.00 € (+25.0 %)", table.Rows[1].Cells[3].Text);
|
|
Assert.Equal("mv-change-bad", table.Rows[1].Cells[3].CssClass);
|
|
Assert.Equal("+10.00 € (+25.0 %)", table.Rows[0].Cells[3].Text);
|
|
});
|
|
|
|
[Theory]
|
|
[InlineData(120, 100, "en", "20 kWh more (+20.0 %)")]
|
|
[InlineData(80, 100, "en", "20 kWh less (-20.0 %)")]
|
|
[InlineData(50, 0, "en", "50 kWh more (percentage not applicable)")]
|
|
[InlineData(-40, -50, "en", "10 kWh more (percentage not applicable)")]
|
|
[InlineData(100, 100, "en", "No change")]
|
|
[InlineData(120, 100, "de", "20 kWh mehr (+20,0 %)")]
|
|
[InlineData(50, 0, "de", "50 kWh mehr (keine Prozentangabe möglich)")]
|
|
public void A_change_reads_as_words_with_its_absolute_difference(double current, double previous, string culture, string expected) =>
|
|
Assert.Equal(expected, In(culture, () => ChangeDisplay.Words(Change.Between(current, previous), v => Format.Quantity(v, "kWh"))));
|
|
|
|
[Fact]
|
|
public void An_unknown_side_is_no_comparison_not_a_hundred_percent_drop()
|
|
{
|
|
Assert.Equal("No comparison", In("en", () => ChangeDisplay.Words(Change.Between(null, 100), v => Format.Number(v))));
|
|
Assert.Equal("Kein Vergleich", In("de", () => ChangeDisplay.Words(Change.Unavailable, v => Format.Number(v))));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_tone_follows_the_metric_not_the_sign()
|
|
{
|
|
var up = Change.Between(120, 100);
|
|
var down = Change.Between(80, 100);
|
|
|
|
Assert.Equal(ChangeTone.Bad, ChangeDisplay.Tone(up, ChangePolarities.For(QuantityKind.Consumption)));
|
|
Assert.Equal(ChangeTone.Good, ChangeDisplay.Tone(down, ChangePolarities.For(AnalysisMetric.Cost)));
|
|
Assert.Equal(ChangeTone.Good, ChangeDisplay.Tone(up, ChangePolarities.For(QuantityKind.Generation)));
|
|
Assert.Equal(ChangeTone.Bad, ChangeDisplay.Tone(down, ChangePolarities.For(AnalysisMetric.Export)));
|
|
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(up, ChangePolarities.For(QuantityKind.Net)));
|
|
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(up, ChangePolarities.For(AnalysisMetric.Balance)));
|
|
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(Change.Between(100, 100), ChangePolarity.HigherIsWorse));
|
|
Assert.Equal(ChangeTone.Neutral, ChangeDisplay.Tone(Change.Unavailable, ChangePolarity.HigherIsWorse));
|
|
|
|
// A credit on either side leaves "more" and "less" without a settled meaning.
|
|
Assert.Equal(ChangePolarity.Neutral, ChangePolarities.ForCost(-5, 10));
|
|
Assert.Equal(ChangePolarity.Neutral, ChangePolarities.ForCost(5, -10));
|
|
Assert.Equal(ChangePolarity.HigherIsWorse, ChangePolarities.ForCost(5, 10));
|
|
Assert.Equal("mv-change-bad", ChangeDisplay.CssClass(ChangeTone.Bad));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_derived_value_names_the_source_it_misses() => In("en", () =>
|
|
{
|
|
var value = new BucketValue(null, BucketStatus.Missing, Provenance.Derived, ValueIssue.MissingSource, null, [9, 5]);
|
|
var names = new Dictionary<int, string> { [5] = "Solar 2", [9] = "Summe Solar" };
|
|
|
|
var status = FigureText.Of(value, id => names.GetValueOrDefault(id));
|
|
|
|
Assert.False(status.IsKnown);
|
|
Assert.True(status.IsQualified);
|
|
Assert.Equal("No data · Calculated", status.Summary);
|
|
Assert.Equal("A source meter has no data here (Solar 2)", status.Detail);
|
|
Assert.Equal("A source meter has no data here (#5)", FigureText.Of(value).Detail);
|
|
});
|
|
|
|
[Fact]
|
|
public void An_opening_balance_or_an_estimate_is_qualified_but_an_estimate_stays_complete()
|
|
{
|
|
var estimate = FigureText.Of(Available(5, Provenance.Imported | Provenance.Estimated));
|
|
Assert.True(estimate.IsComplete);
|
|
Assert.True(estimate.IsQualified);
|
|
|
|
var plain = FigureText.Of(Available(5));
|
|
Assert.True(plain.IsComplete);
|
|
Assert.False(plain.IsQualified);
|
|
|
|
var opening = FigureText.Of(new BucketValue(5, BucketStatus.Partial, Provenance.OpeningBalance, ValueIssue.OpeningBalance));
|
|
Assert.False(opening.IsComplete);
|
|
Assert.True(opening.IsQualified);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_cost_over_incomplete_quantities_says_so() => In("en", () =>
|
|
{
|
|
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
|
|
var none = FigureText.Of(Priced(buckets, 100, null).Total);
|
|
|
|
Assert.False(none.IsKnown);
|
|
Assert.Equal("Not priced (no tariff)", none.Status);
|
|
|
|
var priced = FigureText.Of(Priced(buckets, 100, 0.25).Total);
|
|
Assert.True(priced.IsKnown);
|
|
Assert.True(priced.IsComplete);
|
|
Assert.False(priced.IsQualified);
|
|
});
|
|
|
|
[Fact]
|
|
public void A_cost_with_nothing_booked_reads_no_data_never_priced_beside_a_dash() => In("en", () =>
|
|
{
|
|
// A month with nothing to bill and nothing missing (a manual-cost-only instance between its costs) is unknown —
|
|
// the engine keeps it null (SeededBillTests) — so it is not "Priced" and not complete (§4.3: one meaning).
|
|
var nothing = FigureText.Of(CostAmount.Empty);
|
|
Assert.False(nothing.IsKnown);
|
|
Assert.False(nothing.IsComplete);
|
|
Assert.True(nothing.IsQualified);
|
|
Assert.Equal("No data", nothing.Status);
|
|
Assert.True(FigureText.IsNothingBooked(CostAmount.Empty));
|
|
|
|
var row = TableFigure.Of(CostAmount.Empty, "EUR");
|
|
Assert.Equal("—", row.Text);
|
|
Assert.Equal("No data", row.Status.Status);
|
|
|
|
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
|
|
Assert.False(FigureText.IsNothingBooked(Priced(buckets, 100, 0.25).Total));
|
|
Assert.False(FigureText.IsNothingBooked(Priced(buckets, 100, null).Total));
|
|
});
|
|
}
|