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