using System.Net;
using MeterVault.App;
using MeterVault.App.Analysis;
using MeterVault.App.Components.Shared.Analysis;
using MeterVault.App.Theme;
using MeterVault.Core.Analysis;
using MeterVault.Core.Analysis.Coverage;
using MeterVault.Infrastructure.Analysis;
using MeterVault.Infrastructure.Options;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using MudBlazor.Services;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
///
/// The shared analysis components rendered to HTML with the framework's static (no bUnit, no
/// browser): they render with real MudBlazor services, the title is the page's h1, the toolbar shows the effective
/// dates, the table says "—" and speaks statuses, the empty and error states offer their actions, attention items link
/// to their fix. Static rendering proves the markup, not the interactive chart or navigation — those are covered by the
/// pure model tests and the manual checklist.
///
public sealed class AnalysisComponentRenderTests
{
[Fact]
public async Task The_page_header_is_the_pages_h1_with_breadcrumbs_carrying_the_period()
{
var ytd = AnalysisQuery.Default(AnalysisDefaults.History.ForScope(QueryScope.ForMeter(5))).WithPeriod(PeriodPreset.YearToDate);
RenderFragment crumbs = builder =>
{
builder.OpenComponent(0);
builder.AddComponentParameter(1, nameof(AnalysisBreadcrumbs.Query), ytd);
builder.AddComponentParameter(2, nameof(AnalysisBreadcrumbs.EnergyTypeId), 1);
builder.AddComponentParameter(3, nameof(AnalysisBreadcrumbs.EnergyTypeName), "Strom");
builder.AddComponentParameter(4, nameof(AnalysisBreadcrumbs.MeterId), 5);
builder.AddComponentParameter(5, nameof(AnalysisBreadcrumbs.MeterName), "Wärmepumpe ");
builder.CloseComponent();
};
var raw = await RenderRawAsync("en", new()
{
[nameof(PageHeader.Title)] = "Wärmepumpe ",
[nameof(PageHeader.Description)] = "Heat pump",
[nameof(PageHeader.Breadcrumbs)] = crumbs,
});
var html = WebUtility.HtmlDecode(raw);
// User data is text, never markup; the title is the page's one h1.
Assert.Contains("<Keller>", raw, StringComparison.Ordinal);
Assert.Contains("Wärmepumpe ", html, StringComparison.Ordinal);
Assert.Contains("href=\"/?period=ytd\"", html, StringComparison.Ordinal);
Assert.Contains("href=\"/energy/1?period=ytd\"", html, StringComparison.Ordinal);
Assert.Contains("Wärmepumpe ", html, StringComparison.Ordinal);
Assert.Single(System.Text.RegularExpressions.Regex.Matches(html, "("de", new()
{
[nameof(PeriodToolbar.Query)] = query.WithPeriod(PeriodPreset.Last24Months),
[nameof(PeriodToolbar.Period)] = period,
[nameof(PeriodToolbar.Defaults)] = AnalysisDefaults.History,
[nameof(PeriodToolbar.Plan)] = plan,
[nameof(PeriodToolbar.ExportHref)] = "/export/analysis.csv?period=24m",
});
Assert.Contains("Letzte 24 Monate", html, StringComparison.Ordinal);
Assert.Contains(In("de", () => Format.PeriodRange(period)), html, StringComparison.Ordinal);
Assert.Contains("Das Intervall „Täglich“ bräuchte", html, StringComparison.Ordinal);
Assert.Contains("„Wöchentlich“ verwenden", html, StringComparison.Ordinal);
Assert.Contains("CSV exportieren", html, StringComparison.Ordinal);
Assert.Contains("Zurücksetzen", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_toolbar_shows_notices_for_what_the_address_got_wrong()
{
var query = AnalysisQuery.Parse("?period=fortnight&bucket=hourly", AnalysisDefaults.History);
var html = await RenderAsync("en", new() { [nameof(PeriodToolbar.Query)] = query });
Assert.Equal(2, query.Notices.Count);
foreach (var notice in query.Notices)
{
Assert.Contains(In("en", () => MeterVault.App.Localization.DisplayNames.Display(notice.Kind)), html, StringComparison.Ordinal);
}
Assert.DoesNotContain("fortnight", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_table_says_unknown_and_speaks_the_status()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = In("en", () => AnalysisTableSeries.ForSeries(Series(1, "Haus", [Available(120), Missing(), Available(0)])));
var html = await RenderAsync("en", new()
{
[nameof(AnalysisTable.Buckets)] = buckets,
[nameof(AnalysisTable.Series)] = new[] { series },
[nameof(AnalysisTable.DrillHref)] = (Func)(b => "/drill/" + b.FirstDay.Month),
});
Assert.Contains("Feb ", html, StringComparison.Ordinal);
Assert.Contains(">0 kWh", html, StringComparison.Ordinal);
Assert.Contains("mv-unknown\">—", html, StringComparison.Ordinal);
Assert.Contains("No data covers this period", html, StringComparison.Ordinal);
Assert.Contains("href=\"/drill/2\"", html, StringComparison.Ordinal);
Assert.Contains("aria-label=\"Details for Feb\"", html, StringComparison.Ordinal);
Assert.Contains("role=\"region\"", html, StringComparison.Ordinal);
}
[Fact]
public async Task A_metric_card_shows_words_instead_of_a_fabricated_zero()
{
var unknown = await RenderAsync("en", new()
{
[nameof(MetricCard.Title)] = "Consumption",
[nameof(MetricCard.Value)] = Missing(),
[nameof(MetricCard.Unit)] = "kWh",
});
Assert.Contains("No data", unknown, StringComparison.Ordinal);
Assert.DoesNotContain("0 kWh", unknown, StringComparison.Ordinal);
var partial = await RenderAsync("en", new()
{
[nameof(MetricCard.Title)] = "Consumption",
[nameof(MetricCard.Value)] = Partial(12),
[nameof(MetricCard.Unit)] = "kWh",
[nameof(MetricCard.Change)] = Change.Between(12, 10),
[nameof(MetricCard.Polarity)] = ChangePolarity.HigherIsWorse,
[nameof(MetricCard.ChangeCaption)] = "vs. last year",
});
Assert.Contains("12 kWh", partial, StringComparison.Ordinal);
Assert.Contains("Partial", partial, StringComparison.Ordinal);
Assert.Contains("2.0 kWh more (+20.0 %)", partial, StringComparison.Ordinal);
Assert.Contains("mv-change-bad", partial, StringComparison.Ordinal);
}
[Fact]
public async Task Empty_pending_and_error_states_offer_their_way_on()
{
var empty = await RenderAsync("de", new()
{
[nameof(EmptyPeriodState.Availability)] = new AvailableRange(Now, Now, D(2020, 1, 1), D(2023, 3, 15)),
[nameof(EmptyPeriodState.LatestHref)] = "/meters/5?tab=analysis&from=2022-04-01&to=2023-03-31",
});
Assert.Contains("Keine Daten für diesen Zeitraum", empty, StringComparison.Ordinal);
Assert.Contains("Zu den neuesten Daten", empty, StringComparison.Ordinal);
Assert.Contains("from=2022-04-01", empty, StringComparison.Ordinal);
var nothing = await RenderAsync("en", []);
Assert.Contains("No data yet", nothing, StringComparison.Ordinal);
Assert.DoesNotContain("Go to latest data", nothing, StringComparison.Ordinal);
var pending = await RenderAsync("en", []);
Assert.Contains("Analysis being prepared", pending, StringComparison.Ordinal);
var stale = await RenderAsync("en", new() { [nameof(PanelError.HasStaleValue)] = true });
Assert.Contains("The figures shown are from the previous selection.", stale, StringComparison.Ordinal);
var projection = await RenderAsync("en", new() { [nameof(ProjectionNote.Days)] = 19, [nameof(ProjectionNote.ValueText)] = "320 kWh" });
Assert.Contains("Projection (straight-line from 19 days): ≈ 320 kWh", projection, StringComparison.Ordinal);
}
[Fact]
public async Task A_value_status_names_the_reason_and_the_source_it_misses()
{
var value = new BucketValue(null, BucketStatus.Missing, Provenance.Derived, ValueIssue.MissingSource, null, [9, 5]);
var html = await RenderAsync("de", new()
{
[nameof(ValueStatus.Value)] = value,
[nameof(ValueStatus.Inline)] = true,
[nameof(ValueStatus.MeterName)] = (Func)(id => id == 5 ? "Solar 2" : null),
});
Assert.Contains("Keine Daten · Berechnet", html, StringComparison.Ordinal);
Assert.Contains("Einem Quellzähler fehlen hier Daten (Solar 2)", html, StringComparison.Ordinal);
// A plain complete value says nothing unless asked to.
Assert.Equal(string.Empty, (await RenderAsync("en", new() { [nameof(ValueStatus.Value)] = Available(3) })).Trim());
Assert.Contains("Complete · Measured", await RenderAsync("en", new()
{
[nameof(ValueStatus.Value)] = Available(3),
[nameof(ValueStatus.ShowWhenComplete)] = true,
}), StringComparison.Ordinal);
}
[Fact]
public async Task A_comparison_summary_names_both_ranges_or_why_there_is_none()
{
var period = Range(D(2026, 1, 1), D(2026, 3, 31));
var shown = await RenderAsync("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear)),
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
});
Assert.Contains("Jan 1 – Mar 31, 2026 compared with Jan 1 – Mar 31, 2025", shown, StringComparison.Ordinal);
Assert.Contains("Not comparable", shown, StringComparison.Ordinal);
var all = await RenderAsync("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = new ComparisonResolution(ComparisonRequest.None, null, ComparisonUnavailableReason.AllHistory),
});
Assert.Contains("No comparison: All history has nothing before it to compare with.", all, StringComparison.Ordinal);
}
[Fact]
public async Task Attention_items_render_with_their_actions_and_severity_words()
{
var html = await RenderAsync("en", new()
{
[nameof(AttentionList.Problems)] = new[] { new AnalysisProblem(AnalysisProblemKind.StaleSource, 3) },
[nameof(AttentionList.Names)] = new AttentionNames(new Dictionary { [3] = "Solar 1" }),
});
Assert.Contains("Needs attention", html, StringComparison.Ordinal);
Assert.Contains("Warning:", html, StringComparison.Ordinal);
Assert.Contains("Solar 1: the live source has stopped delivering.", html, StringComparison.Ordinal);
Assert.Contains("href=\"/meters/3?tab=sources\"", html, StringComparison.Ordinal);
Assert.Equal(string.Empty, (await RenderAsync("en", [])).Trim());
}
[Fact]
public async Task Virtual_sources_show_the_formula_with_names_and_nested_sources_indented()
{
var nested = new SeriesContribution(7, "Solar 2", false, 1, [], [], Available(150), 150, [9, 8, 7], []);
var inner = new SeriesContribution(8, "Dach", true, 1, [], [], Available(150), 150, [9, 8], [nested]);
var first = new SeriesContribution(3, "Solar 1", false, 1, [], [], Available(100), 100, [9, 3], []);
var series = Series(9, "Summe Solar", [Available(250)], kind: QuantityKind.Generation) with
{
Basis = SeriesBasis.Virtual,
Contributions = [first, inner],
Virtual = new VirtualSeriesInfo(VirtualMeterStatus.Valid, "m3 + m8", Core.Analysis.Virtual.VirtualCostRule.SourceCosts, [3, 8], [3, 7], [], null, null),
};
var html = await RenderAsync("de", new() { [nameof(SeriesContributions.Series)] = series });
Assert.Contains(">Solar 1", html, StringComparison.Ordinal);
Assert.Contains("m3", html, StringComparison.Ordinal);
Assert.Contains("m8", html, StringComparison.Ordinal);
// The nested source is indented with an invariant CSS number, even for a German reader.
Assert.Contains("padding-left:2rem", html, StringComparison.Ordinal);
Assert.Contains("· berechnet", html, StringComparison.Ordinal);
Assert.Contains("href=\"/meters/7?tab=analysis\"", html, StringComparison.Ordinal);
Assert.Contains("Kosten: Summe der Kosten der Quellen", html, StringComparison.Ordinal);
}
[Fact]
public async Task The_chart_renders_its_container_or_says_there_is_nothing()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var none = await RenderAsync("en", new()
{
[nameof(AnalysisChart.Buckets)] = buckets,
[nameof(AnalysisChart.Series)] = new[] { new AnalysisChartSeries("m1", "Haus", "kWh", [Missing(), Missing()]) },
});
Assert.Contains("No data in this range.", none, StringComparison.Ordinal);
var some = await RenderAsync("en", new()
{
[nameof(AnalysisChart.Buckets)] = buckets,
[nameof(AnalysisChart.Series)] = new[] { new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Partial(2)]) },
[nameof(AnalysisChart.Title)] = "Consumption of Haus",
});
Assert.Contains("aria-label=\"Chart: Consumption of Haus. The table lists the same values.\"", some, StringComparison.Ordinal);
Assert.Contains("* Partial, estimated or not fully priced", some, StringComparison.Ordinal);
}
/// Renders a component and returns its HTML with entities decoded (the renderer encodes every non-ASCII letter).
private static async Task RenderAsync(string culture, Dictionary parameters)
where TComponent : IComponent => WebUtility.HtmlDecode(await RenderRawAsync(culture, parameters));
private static async Task RenderRawAsync(string culture, Dictionary parameters)
where TComponent : IComponent
{
await using var services = Services();
using var loggers = new NullLoggerFactory();
await using var renderer = new HtmlRenderer(services, loggers);
var previous = (System.Globalization.CultureInfo.CurrentCulture, System.Globalization.CultureInfo.CurrentUICulture);
try
{
System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo(culture);
System.Globalization.CultureInfo.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo(culture);
return await renderer.Dispatcher.InvokeAsync(async () =>
{
var output = await renderer.RenderComponentAsync(ParameterView.FromDictionary(parameters));
return output.ToHtmlString();
});
}
finally
{
System.Globalization.CultureInfo.CurrentCulture = previous.Item1;
System.Globalization.CultureInfo.CurrentUICulture = previous.Item2;
}
}
private static ServiceProvider Services()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMudServices();
services.AddSingleton();
services.AddSingleton();
services.AddScoped();
services.AddSingleton(Options.Create(new MeterVaultOptions()));
services.AddSingleton();
return services.BuildServiceProvider();
}
/// Static rendering runs no script; anything that asks gets nothing back.
private sealed class NoJs : IJSRuntime
{
public ValueTask InvokeAsync(string identifier, object?[]? args) => ValueTask.FromResult(default(TValue)!);
public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) =>
ValueTask.FromResult(default(TValue)!);
}
private sealed class TestNavigation : NavigationManager
{
public TestNavigation() => Initialize("http://localhost/", "http://localhost/meters/5?tab=analysis");
protected override void NavigateToCore(string uri, NavigationOptions options)
{
}
}
}