using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Costing;
using MeterVault.Core.Analysis.Quantities;
using MeterVault.Core.Analysis.Totals;
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Costing;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
///
/// Attention items (D-52, D-53): each reader code becomes a localized one-liner with its one targeted action — the
/// prefilled tariff editor for a missing price, the Calculation tab for a definition to fix, the Sources tab for a stale
/// source, Normalized data around rows after now, the energy type's Meters tab for a possible overlap — carrying the
/// period; unknown kinds degrade to their wording. Pure; no database.
///
public sealed class AttentionItemsTests
{
private static readonly AttentionNames Names = new(
new Dictionary { [1] = "Haus", [2] = "Netz", [3] = "Solar 1", [5] = "Auto", [9] = "Summe Solar" },
new Dictionary { [1] = "Strom" });
/// A meter page on "this year to date": links carry the period.
private static readonly AnalysisQuery Ytd = AnalysisQuery.Default(AnalysisDefaults.History).WithPeriod(PeriodPreset.YearToDate);
[Fact]
public void A_missing_price_opens_the_tariff_editor_for_its_scope_component_and_first_month() => In("en", () =>
{
var gap = new MissingPrice(TariffComponent.UnitPrice, CostStatus.PriceGap, TariffScope.EnergyType, 1, null, D(2024, 1, 1), D(2024, 3, 1));
var item = AttentionItems.ForCost(new CostAttention(CostAttentionKind.MissingPrice, null) { Price = gap }, Names);
Assert.Equal(AttentionSeverity.Warning, item.Severity);
Assert.Equal("Strom: Unit price missing from Jan 2024.", item.Text);
Assert.Equal("Add tariff", item.ActionText);
Assert.Equal("/admin/tariffs?scope=type&id=1&component=unit-price&from=2024-01-01&action=new", item.ActionHref);
});
[Fact]
public void Missing_prices_are_worded_by_reason_and_an_absent_credit_is_only_a_note() => In("de", () =>
{
var none = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.NotPriced, TariffScope.EnergyType, 1, 2, D(2026, 1, 1), D(2026, 9, 1)), Names);
Assert.Equal("Netz: Arbeitspreis nicht hinterlegt.", none.Text);
Assert.Equal("Tarif anlegen", none.ActionText);
var mismatch = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.Meter, 2, 2, D(2025, 2, 1), D(2025, 2, 1), TariffId: 7), Names);
Assert.Equal("Netz: Arbeitspreis passt ab Feb 2025 nicht zur Einheit des Zählers.", mismatch.Text);
Assert.Equal("Tarif korrigieren", mismatch.ActionText);
Assert.Equal("/admin/tariffs?scope=meter&id=2&component=unit-price&from=2025-02-01&action=new", mismatch.ActionHref);
var credit = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.FeedIn, CostStatus.PriceGap, TariffScope.EnergyType, 1, 4, D(2026, 3, 1), D(2026, 3, 1)), Names);
Assert.Equal(AttentionSeverity.Info, credit.Severity);
Assert.Contains("die Gutschrift ist nicht enthalten", credit.Text, StringComparison.Ordinal);
Assert.Contains("Zähler #4", credit.Text, StringComparison.Ordinal);
var global = AttentionItems.MissingPrice(
new MissingPrice(TariffComponent.BasePrice, CostStatus.PriceGap, TariffScope.Global, null, null, D(2026, 1, 1), D(2026, 1, 1)), Names);
Assert.StartsWith("Alle Energiearten: Grundpreis fehlt ab", global.Text, StringComparison.Ordinal);
Assert.Equal("/admin/tariffs?scope=global&component=base-price&from=2026-01-01&action=new", global.ActionHref);
});
[Fact]
public void A_currency_or_base_price_mismatch_says_what_does_not_fit_not_the_meters_unit()
{
// D-37: an EUR tariff in a USD instance, or a base price quoted per a period that cannot be accrued, is not a
// problem of the meter's unit — the item says what to fix.
var currency = new MissingPrice(
TariffComponent.UnitPrice, CostStatus.UnitMismatch, TariffScope.Meter, 2, 2, D(2025, 1, 1), D(2025, 1, 1), TariffId: 7, Issue: TariffUnitIssue.CurrencyMismatch);
var basePrice = new MissingPrice(
TariffComponent.BasePrice, CostStatus.UnitMismatch, TariffScope.Meter, 2, 2, D(2025, 1, 1), D(2025, 1, 1), TariffId: 8, Issue: TariffUnitIssue.UnsupportedPeriod);
In("en", () =>
{
Assert.Equal("Netz: Unit price is quoted in another currency than this instance uses, from Jan 2025.", AttentionItems.MissingPrice(currency, Names).Text);
var item = AttentionItems.MissingPrice(basePrice, Names);
Assert.Equal("Netz: the unit of Base price cannot be used from Jan 2025 – a base price is quoted per day, month or year.", item.Text);
Assert.DoesNotContain("meter's unit", item.Text, StringComparison.Ordinal);
Assert.Equal("Fix tariff", item.ActionText);
});
In("de", () =>
{
Assert.Equal("Netz: Arbeitspreis ist ab Jan 2025 in einer anderen Währung angegeben, als diese Instanz verwendet.", AttentionItems.MissingPrice(currency, Names).Text);
Assert.DoesNotContain("Einheit des Zählers", AttentionItems.MissingPrice(basePrice, Names).Text, StringComparison.Ordinal);
});
}
[Fact]
public void A_category_whose_members_price_nothing_names_them_and_leads_to_the_categories()
{
var names = new AttentionNames(
new Dictionary { [9] = "Summe Solar", [3] = "Solar 1" }, null, new Dictionary { [4] = "PV view" });
var attention = new CostAttention(CostAttentionKind.CategoryPricesNothing, 9) { CategoryId = 4, MeterIds = [9, 3] };
In("en", () =>
{
var item = AttentionItems.ForCost(attention, names);
Assert.Equal(AttentionSeverity.Info, item.Severity);
Assert.StartsWith("PV view: Summe Solar, Solar 1 add nothing to this category's cost", item.Text, StringComparison.Ordinal);
Assert.Equal("Edit categories", item.ActionText);
Assert.Equal("/admin/categories", item.ActionHref);
});
In("de", () => Assert.StartsWith(
"PV view: Summe Solar, Solar 1 tragen nichts zu den Kosten dieser Kategorie bei", AttentionItems.ForCost(attention, names).Text, StringComparison.Ordinal));
}
[Theory]
[InlineData(AnalysisProblemKind.InvalidDefinition, AttentionSeverity.Error, "Edit calculation")]
[InlineData(AnalysisProblemKind.MalformedDefinition, AttentionSeverity.Error, "Edit calculation")]
[InlineData(AnalysisProblemKind.LegacyNeedsConfiguration, AttentionSeverity.Error, "Set up calculation")]
[InlineData(AnalysisProblemKind.LegacyDefinition, AttentionSeverity.Info, "Confirm calculation")]
public void A_calculation_to_fix_opens_the_meters_calculation_tab_with_the_period(AnalysisProblemKind kind, AttentionSeverity severity, string action) => In("en", () =>
{
var item = AttentionItems.ForProblem(new AnalysisProblem(kind, 9), Names, Ytd);
Assert.Equal(severity, item.Severity);
Assert.StartsWith("Summe Solar", item.Text, StringComparison.Ordinal);
Assert.Equal(action, item.ActionText);
Assert.Equal("/meters/9?tab=calculation&period=ytd", item.ActionHref);
});
[Fact]
public void Sources_rows_after_now_overlaps_and_conflicts_each_get_their_own_place() => In("en", () =>
{
var stale = AttentionItems.ForProblem(new AnalysisProblem(AnalysisProblemKind.StaleSource, 3), Names);
Assert.Equal("Solar 1: the live source has stopped delivering.", stale.Text);
Assert.Equal("/meters/3?tab=sources", stale.ActionHref);
var afterNow = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.RecordedAfterNow, 5) { AfterNow = new RecordedAfterNow(5, 1, 42, D(2026, 9, 30), D(2026, 9, 30)) },
Names);
Assert.Equal("Auto: values dated after now (Sep 30, 2026) are not counted yet.", afterNow.Text);
Assert.Equal("/meters/5?tab=normalized&from=2026-09-30&to=2026-09-30", afterNow.ActionHref);
var overlap = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.PossibleOverlap, 2)
{
MeterIds = [1],
Hint = new OverlapHint(OverlapHintKind.GridImportNotLinkedToTotalLoad, 1, 2, 1),
},
Names,
Ytd);
Assert.Equal("Netz and Haus are not linked, so they may count the same energy twice.", overlap.Text);
Assert.Equal("Manage meters", overlap.ActionText);
Assert.Equal("/energy/1?tab=meters&period=ytd", overlap.ActionHref);
var conflict = AttentionItems.ForProblem(
new AnalysisProblem(AnalysisProblemKind.TotalsProblem, 2)
{
MeterIds = [1],
Totals = new TotalsProblem(TotalsProblemKind.DuplicateRole, 2, 1, MeterRole.GridImport),
},
Names);
Assert.Equal(
"Netz and Haus: the totals configuration contradicts itself. Two meters hold the same role at the same time; the one created first keeps it (Grid import).",
conflict.Text);
Assert.Equal("/meters/2?tab=analysis&action=edit", conflict.ActionHref);
var pending = AttentionItems.ForProblem(new AnalysisProblem(AnalysisProblemKind.AnalysisPending, 1), Names);
Assert.Equal(AttentionSeverity.Info, pending.Severity);
Assert.Null(pending.ActionHref);
Assert.Null(pending.ActionText);
});
[Fact]
public void An_unknown_kind_degrades_to_its_wording_without_an_action() => In("en", () =>
{
var item = AttentionItems.ForProblem(new AnalysisProblem((AnalysisProblemKind)999, 1), Names);
Assert.Equal("Haus: 999", item.Text);
Assert.Null(item.ActionHref);
var cost = AttentionItems.ForCost(new CostAttention((CostAttentionKind)999, null), Names);
Assert.Equal("999", cost.Text);
Assert.Null(cost.ActionHref);
});
[Fact]
public void Items_collapse_duplicates_and_put_errors_first() => In("en", () =>
{
AnalysisProblem[] problems =
[
new(AnalysisProblemKind.AnalysisPending, 1),
new(AnalysisProblemKind.StaleSource, 3),
new(AnalysisProblemKind.InvalidDefinition, 9),
new(AnalysisProblemKind.StaleSource, 3),
];
// The cost reader repeats the quantity reader's problems; they appear once.
var items = AttentionItems.Build(problems, [new CostAttention(CostAttentionKind.ManualCostAfterToday, null) { ManualCostIds = [4, 6] }], Names);
Assert.Equal(
[AttentionSeverity.Error, AttentionSeverity.Warning, AttentionSeverity.Info, AttentionSeverity.Info],
items.Select(i => i.Severity));
Assert.Equal("Summe Solar: the calculation is invalid, so no values can be shown.", items[0].Text);
Assert.Equal("Manual costs dated after today are not counted yet (2).", items[3].Text);
Assert.Empty(AttentionItems.Build(null, null, Names));
});
[Fact]
public void Names_come_from_the_results_and_fall_back_to_the_id() => In("de", () =>
{
var names = new AttentionNames();
Assert.Equal("Zähler #12", names.Meter(12));
Assert.Equal("Energieart #3", names.EnergyType(3));
Assert.Null(names.MeterOrNull(12));
var nested = new SeriesContribution(7, "Solar 2", false, 1, [], [], Available(1), 1, [9, 7], []);
var virtualSeries = Series(9, "Summe Solar", [Available(1)]) with { Contributions = [new SeriesContribution(3, "Solar 1", true, 1, [], [], Available(1), 1, [9, 3], [nested])] };
var result = new AnalysisResult(
new AnalysisRequest(AnalysisScope.ForMeter(9), Range(D(2026, 1, 1), D(2026, 1, 31))),
BucketPlanner.Plan(Range(D(2026, 1, 1), D(2026, 1, 31)), BucketSize.Month),
[virtualSeries],
[],
ScopeAvailability.None,
[]);
var fromResult = AttentionNames.From(result);
Assert.Equal("Summe Solar", fromResult.Meter(9));
Assert.Equal("Solar 1", fromResult.Meter(3));
Assert.Equal("Solar 2", fromResult.Meter(7));
});
}