Files
MeterVault/tests/Integration.Tests/Analysis/AnalysisComponentRenderTests.cs
T
Florian Schmidt a08e9f781f
ci / build-test (push) Successful in 2m41s
Analysis: read a rarely-read meter as coarse, not absent; newest rows first
Three things a reported Heizoel page got wrong at once. Its tank is dipped
a few times a year and its burner read every few months, which is exactly
the shape the coverage rules had not been walked through.

"No data" for data that exists. A tank books nothing until the next
dipstick closes the interval, so the stretch after the last dipstick is
covered by no run at all, and a bucket no run covers was reported missing.
The burner, whose run reaches into the window, said "only coarser data" --
the honest answer -- so one card claimed there was nothing while the
coverage panel beside it listed years of data. A bucket that no run covers,
no gap overlaps and no opening balance explains now reports the meter's
resolution when its preceding coverage is within one interval of its own
class: it is not silent, it is read rarely. A meter that does book its own
buckets and stops -- a dead hourly source, a sheet asked about a later
month -- still reads missing.

Auto answering twelve months with one bar. Coarse only means "longer than
a month", so a dipstick taken each autumn straddles a New Year as surely
as a month start: coarsening the chart to years bought nothing and cost
every point. The planning resolution now caps coarse at month when a run
crosses a local year edge, and a series that cannot resolve the natural
size no longer coarsens the whole chart -- it is drawn at that size with
its buckets marked, which the chart and table already explain.

A page contradicting itself. The comparison line above the ranking was fed
the leading measure's matched coverage but worded as if it spoke for the
page, directly above a burner row that did compare. It now names the figure
it is about.

Alongside: the "largest changes" ranking no longer drops a meter whose
change is not comparable. It ranks what can be ranked, then lists the rest
with their values and the reason -- the tank had been vanishing from its
own energy type. And every dated table now reads newest first, as lists
are read; charts stay chronological left to right, and the CSV export
stays ascending for spreadsheets.

A-41 to A-43 in the note record the three rules.
2026-09-20 12:44:32 +02:00

404 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>
/// The shared analysis components rendered to HTML with the framework's static <see cref="HtmlRenderer"/> (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.
/// </summary>
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<AnalysisBreadcrumbs>(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 <Keller>");
builder.CloseComponent();
};
var raw = await RenderRawAsync<PageHeader>("en", new()
{
[nameof(PageHeader.Title)] = "Wärmepumpe <Keller>",
[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("&lt;Keller&gt;</h1>", raw, StringComparison.Ordinal);
Assert.Contains("<h1 class=\"mud-typography mud-typography-h4 mv-page-header__h1\">Wärmepumpe <Keller></h1>", html, StringComparison.Ordinal);
Assert.Contains("href=\"/?period=ytd\"", html, StringComparison.Ordinal);
Assert.Contains("href=\"/energy/1?period=ytd\"", html, StringComparison.Ordinal);
Assert.Contains("<nav aria-label=\"Breadcrumbs\"", html, StringComparison.Ordinal);
Assert.Contains("<span aria-current=\"page\">Wärmepumpe <Keller></span>", html, StringComparison.Ordinal);
Assert.Single(System.Text.RegularExpressions.Regex.Matches(html, "<h1"));
}
[Fact]
public async Task The_toolbar_shows_the_preset_with_its_effective_dates_and_a_refused_bucket()
{
var query = AnalysisQuery.Default(AnalysisDefaults.History).WithBucket(BucketSize.Day);
var period = query.WithPeriod(PeriodPreset.Last24Months).Resolve(Now, Berlin);
var plan = BucketPlanner.Plan(period, BucketSize.Day, maxPoints: AnalysisLimits.MaxPoints);
Assert.True(plan.Refused);
var html = await RenderAsync<PeriodToolbar>("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<PeriodToolbar>("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<AnalysisTable>("en", new()
{
[nameof(AnalysisTable.Buckets)] = buckets,
[nameof(AnalysisTable.Series)] = new[] { series },
[nameof(AnalysisTable.DrillHref)] = (Func<AnalysisBucket, string?>)(b => "/drill/" + b.FirstDay.Month),
});
Assert.Contains("<th scope=\"row\" class=\"mv-row-label\">Feb</th>", html, StringComparison.Ordinal);
Assert.Contains(">0 kWh", html, StringComparison.Ordinal);
// A-42: the total above the rows, then March, February, January.
Assert.Equal(
["Total", "Mar", "Feb", "Jan"],
System.Text.RegularExpressions.Regex.Matches(html, "<th scope=\"row\" class=\"mv-row-label\">([^<]+)</th>")
.Select(m => m.Groups[1].Value));
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<MetricCard>("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<MetricCard>("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<EmptyPeriodState>("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<EmptyPeriodState>("en", []);
Assert.Contains("No data yet", nothing, StringComparison.Ordinal);
Assert.DoesNotContain("Go to latest data", nothing, StringComparison.Ordinal);
var pending = await RenderAsync<PendingState>("en", []);
Assert.Contains("Analysis being prepared", pending, StringComparison.Ordinal);
var stale = await RenderAsync<PanelError>("en", new() { [nameof(PanelError.HasStaleValue)] = true });
Assert.Contains("The figures shown are from the previous selection.", stale, StringComparison.Ordinal);
var projection = await RenderAsync<ProjectionNote>("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<ValueStatus>("de", new()
{
[nameof(ValueStatus.Value)] = value,
[nameof(ValueStatus.Inline)] = true,
[nameof(ValueStatus.MeterName)] = (Func<int, string?>)(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<ValueStatus>("en", new() { [nameof(ValueStatus.Value)] = Available(3) })).Trim());
Assert.Contains("Complete · Measured", await RenderAsync<ValueStatus>("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<ComparisonSummary>("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<ComparisonSummary>("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<AttentionList>("en", new()
{
[nameof(AttentionList.Problems)] = new[] { new AnalysisProblem(AnalysisProblemKind.StaleSource, 3) },
[nameof(AttentionList.Names)] = new AttentionNames(new Dictionary<int, string> { [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<AttentionList>("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<SeriesContributions>("de", new() { [nameof(SeriesContributions.Series)] = series });
Assert.Contains(">Solar 1</a>", html, StringComparison.Ordinal);
Assert.Contains("<code>m3</code>", html, StringComparison.Ordinal);
Assert.Contains("<code>m8</code>", 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<AnalysisChart>("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<AnalysisChart>("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);
}
[Fact]
public async Task The_comparison_line_names_the_figure_it_is_about_when_a_page_shows_several()
{
// A-41: on one energy type page the tank's use shares no covered day with the year before while the burner
// beside it compares month by month. Unqualified, the line contradicts the card and the table under it.
var period = Range(D(2025, 10, 1), D(2026, 9, 30));
var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear), Now);
var alone = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
});
Assert.Contains("Not comparable: the two periods share no covered days", alone, StringComparison.Ordinal);
var named = await RenderAsync<ComparisonSummary>("en", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = MatchedCoverageResult.NotComparable,
[nameof(ComparisonSummary.Subject)] = "Total use",
});
Assert.Contains("Total use: not comparable — the two periods share no covered days", named, StringComparison.Ordinal);
Assert.DoesNotContain("Not comparable: the two", named, StringComparison.Ordinal);
var matched = await RenderAsync<ComparisonSummary>("de", new()
{
[nameof(ComparisonSummary.Period)] = period,
[nameof(ComparisonSummary.Resolution)] = resolution,
[nameof(ComparisonSummary.Matched)] = Matched(D(2025, 10, 1), D(2026, 5, 1)),
[nameof(ComparisonSummary.Subject)] = "Laufzeit",
});
Assert.Contains("Laufzeit: Veränderung über den gemeinsam abgedeckten Zeitraum ", matched, StringComparison.Ordinal);
}
/// <summary>One matched stretch of the current period and the same stretch a year earlier.</summary>
private static MatchedCoverageResult Matched(DateOnly first, DateOnly last)
{
MatchedRange RangeOf(int years) => new(
new DateTimeOffset(first.AddYears(years).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
new DateTimeOffset(last.AddYears(years).AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
first.AddYears(years),
last.AddYears(years));
var piece = new MatchedPiece(RangeOf(0), RangeOf(-1));
return new MatchedCoverageResult(piece.Current, piece.Comparison, [piece]);
}
/// <summary>Renders a component and returns its HTML with entities decoded (the renderer encodes every non-ASCII letter).</summary>
private static async Task<string> RenderAsync<TComponent>(string culture, Dictionary<string, object?> parameters)
where TComponent : IComponent => WebUtility.HtmlDecode(await RenderRawAsync<TComponent>(culture, parameters));
private static async Task<string> RenderRawAsync<TComponent>(string culture, Dictionary<string, object?> 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<TComponent>(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<IJSRuntime, NoJs>();
services.AddSingleton<NavigationManager, TestNavigation>();
services.AddScoped<ThemeState>();
services.AddSingleton(Options.Create(new MeterVaultOptions()));
services.AddSingleton<InstanceCurrency>();
return services.BuildServiceProvider();
}
/// <summary>Static rendering runs no script; anything that asks gets nothing back.</summary>
private sealed class NoJs : IJSRuntime
{
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args) => ValueTask.FromResult(default(TValue)!);
public ValueTask<TValue> InvokeAsync<TValue>(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)
{
}
}
}