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);
// 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 { [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));
});
}