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