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; /// /// 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. /// 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); Assert.Equal(["Jan", "Feb", "Mar", "Total"], table.Rows.Select(r => r.Label)); Assert.Equal(buckets[1], table.Rows[1].Bucket); Assert.Null(table.Rows[3].Bucket); Assert.True(table.Rows[3].IsTotal); Assert.Equal(["120 kWh", "—", "0 kWh", "120 kWh"], table.Rows.Select(r => r.Cells[0].Text)); Assert.True(table.Rows[1].Cells[0].IsUnknown); Assert.False(table.Rows[2].Cells[0].IsUnknown); Assert.Equal("Complete · Measured", table.Rows[0].Cells[1].Text); Assert.Equal("No data", table.Rows[1].Cells[1].Text); Assert.Equal("No data covers this period", table.Rows[1].Cells[1].Secondary); Assert.Equal("Partial · Measured", table.Rows[3].Cells[1].Text); Assert.Equal([false, true, false, true], table.Rows.Select(r => r.IsQualified)); }); [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(); Assert.Equal(["100 kWh", "90 kWh", "—", "190 kWh"], comparison.Select(c => c.Text)); Assert.Equal(["Jan 2025", "Feb 2025", "Mar 2025", null], 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[0].Text); Assert.Equal("mv-change-bad", change[0].CssClass); Assert.Equal("—", change[1].Text); Assert.True(change[1].IsUnknown); Assert.Equal("—", change[2].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[3].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.Equal("mv-change-good", table.Rows[0].Cells[3].CssClass); Assert.Equal("mv-change-neutral", table.Rows[0].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(["—", "25.00 €", "25.00 €", "50.00 €"], table.Rows.Select(r => r.Cells[2].Text)); Assert.Equal("Unavailable (tariff gap)", table.Rows[0].Cells[3].Text); Assert.Equal("Priced", table.Rows[1].Cells[3].Text); Assert.Equal("Partly priced", table.Rows[3].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(["25.00 €", "25.00 €", "50.00 €"], table.Rows.Select(r => r.Cells[0].Text)); Assert.Equal("+5.00 € (+25.0 %)", table.Rows[0].Cells[3].Text); Assert.Equal("mv-change-bad", table.Rows[0].Cells[3].CssClass); Assert.Equal("+10.00 € (+25.0 %)", table.Rows[2].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 { [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)); }); }