using System.Globalization;
using ApexCharts;
using MeterVault.App.Analysis;
using MeterVault.Core.Analysis;
using static MeterVault.Integration.Tests.Analysis.AnalysisUiTestData;
namespace MeterVault.Integration.Tests.Analysis;
///
/// The analysis chart's plan and options (D-49, brief §8), without the chart library's browser half: unknown buckets
/// stay gaps and a true zero stays a point, qualified buckets are marked in words and shape, overlays pair with their
/// buckets by index (A-10), labels carry the year across years, units never share an axis, the baseline is a real zero,
/// and the theme reaches the options. Pure; no database.
///
public sealed class AnalysisChartModelTests
{
private static readonly ChartPalette Dark = ChartPalette.For(isDark: true);
[Fact]
public void Unknown_buckets_stay_gaps_and_a_true_zero_stays_a_point() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = new AnalysisChartSeries("m1", "Haus", "kWh", [Available(100), Missing(), Available(0)]);
var plan = AnalysisChartPlan.Build(buckets, [series], Dark);
var points = plan.Panels.Single().Series.Single().Points;
Assert.Equal([100m, null, 0m], points.Select(p => p.Value));
Assert.Equal([false, true, false], points.Select(p => p.IsQualified));
Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.GapMarker, "Mar"], plan.Panels[0].Labels);
Assert.Equal(["Jan", "Feb", "Mar"], plan.Labels);
Assert.Equal("—", points[1].Tooltip.Split(" · ")[0]);
Assert.Contains("No data", points[1].Tooltip, StringComparison.Ordinal);
Assert.Equal("0 kWh", points[2].Tooltip);
Assert.True(plan.HasValues);
});
[Fact]
public void A_series_shorter_than_the_plan_is_unknown_at_the_end_never_zero()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", [Available(5)])], Dark);
Assert.Equal([5m, null, null], plan.Panels[0].Series[0].Points.Select(p => p.Value));
Assert.Equal([false, true, true], plan.Marked);
}
[Fact]
public void A_true_zero_bar_is_drawn_on_the_baseline_and_a_bucket_without_value_is_marked_as_such() => In("en", () =>
{
// Brief §4.3 / DoD: a valid zero is an actual chart point, and it must not look like a month without data. A bar
// series draws an outline, so a zero is a line on the baseline; a gap has no bar and its own mark and note.
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Auto", "kWh", [Available(0), Missing(), Available(0)])], Dark);
var panel = plan.Panels.Single();
Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.GapMarker, "Mar"], panel.Labels);
Assert.False(panel.HasMarked);
Assert.True(panel.HasGaps);
Assert.True(plan.HasValues);
Assert.Equal([0m, null, 0m], panel.Series[0].Points.Select(p => p.Value));
Assert.True(panel.Series[0].StrokeWidth >= 1);
// A partial month keeps the "*" of a qualified value; both marks can meet in one bucket of two series.
var both = AnalysisChartPlan.Build(
buckets,
[
new AnalysisChartSeries("m1", "Auto", "kWh", [Available(1), Partial(2), Available(3)]),
new AnalysisChartSeries("m2", "Haus", "kWh", [Available(1), Missing(), Available(3)]),
],
Dark);
Assert.Equal(["Jan", "Feb" + AnalysisChartPlan.Marker + AnalysisChartPlan.GapMarker, "Mar"], both.Panels[0].Labels);
Assert.True(both.Panels[0].HasMarked);
Assert.True(both.Panels[0].HasGaps);
});
[Fact]
public void A_plan_without_values_says_why_coarser_data_or_no_price_rather_than_no_data() => In("en", () =>
{
var buckets = Buckets(D(2026, 5, 1), D(2026, 5, 3), BucketSize.Day);
var unresolved = new BucketValue(null, BucketStatus.Unresolved, Provenance.Measured, ValueIssue.CoarseResolution);
var coarse = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Wasser", "m³", [unresolved, unresolved, unresolved])], Dark);
Assert.False(coarse.HasValues);
Assert.Equal(ChartEmptyReason.Unresolved, coarse.EmptyReason);
var nothing = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Wasser", "m³", [Missing(), Missing(), Missing()])], Dark);
Assert.Equal(ChartEmptyReason.NoData, nothing.EmptyReason);
// Valid quantities without any tariff: the cost is unavailable, and the chart says so in the cost card's words.
var months = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var none = Priced(months, 100, null).Lines[0].Buckets;
var unpriced = AnalysisChartPlan.Build(months, [AnalysisChartSeries.ForCost("c", "Heizöl", "EUR", none)], Dark);
Assert.False(unpriced.HasValues);
Assert.Equal(ChartEmptyReason.NotPriced, unpriced.EmptyReason);
Assert.Equal("Not priced (no tariff)", unpriced.EmptyStatus);
Assert.Equal(ChartEmptyReason.None, AnalysisChartPlan.Build(months, [new AnalysisChartSeries("m1", "Haus", "kWh", [Available(0), Missing(), Missing()])], Dark).EmptyReason);
});
[Fact]
public void Nothing_known_draws_nothing()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", [Missing(), Missing()])], Dark);
Assert.False(plan.HasValues);
}
[Fact]
public void Partial_and_estimated_buckets_are_marked_in_words_and_faded_not_by_colour_alone() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var values = new[] { Available(10), Partial(4), Available(8, Provenance.Measured | Provenance.Estimated) };
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Haus", "kWh", values)], Dark);
var series = plan.Panels[0].Series[0];
var colour = Dark.SeriesColor(0);
Assert.Equal(colour, series.Color);
Assert.Equal(colour, series.Points[0].FillColor);
Assert.Equal(ChartPalette.WithAlpha(colour, AnalysisChartPlan.QualifiedAlpha), series.Points[1].FillColor);
Assert.StartsWith("rgba(", series.Points[2].FillColor, StringComparison.Ordinal);
// The words travel with the point, and the label says "look here" without colour.
Assert.Equal("4.0 kWh · Partial · Measured — Data covers only part of this period", series.Points[1].Tooltip);
Assert.Contains("Estimated", series.Points[2].Tooltip, StringComparison.Ordinal);
Assert.Equal(["Jan", "Feb *", "Mar *"], plan.Panels[0].Labels);
Assert.True(plan.Panels[0].HasMarked);
});
[Fact]
public void A_comparison_overlay_pairs_with_its_buckets_by_index_and_names_its_own() => In("en", () =>
{
var period = Range(D(2026, 1, 1), D(2026, 3, 31));
var buckets = BucketPlanner.Plan(period, BucketSize.Month).Buckets;
var resolution = ComparisonResolver.Resolve(period, new ComparisonRequest(ComparisonKind.PreviousYear));
var pairs = ComparisonResolver.PairBuckets(period, resolution.Period!, buckets);
var reader = Series(
1, "Haus", [Available(120), Available(100), Available(90)],
comparison: Comparison([Available(100), Missing(), Available(90)], Available(190), Change.Unavailable));
var current = AnalysisChartSeries.ForSeries(reader);
var overlay = AnalysisChartSeries.ComparisonOf(reader, AnalysisChartSeries.ComparisonName("Haus", new ComparisonRequest(ComparisonKind.PreviousYear)))!;
var plan = AnalysisChartPlan.Build(buckets, [current, overlay], Dark, pairs);
var drawn = plan.Panels.Single().Series;
Assert.Equal(2, drawn.Count);
var line = drawn[1];
Assert.True(line.IsComparison);
Assert.Equal(ChartSeriesStyle.Line, line.Style);
Assert.Equal(drawn[0].Color, line.Color);
Assert.Equal(5, line.DashSpace);
Assert.Equal("Haus (Same period last year)", line.Name);
// Point i of the overlay is the image of bucket i: January 2025 beside January 2026, a gap where 2025 had none.
Assert.Equal([100m, null, 90m], line.Points.Select(p => p.Value));
Assert.Equal("Jan 2025: 100 kWh", line.Points[0].Tooltip);
Assert.StartsWith("Feb 2025: —", line.Points[1].Tooltip, StringComparison.Ordinal);
// An overlay's gaps do not mark the current buckets.
Assert.Equal(["Jan", "Feb", "Mar"], plan.Panels[0].Labels);
Assert.False(plan.Panels[0].HasMarked);
});
[Fact]
public void Labels_carry_the_year_across_years_and_stay_distinct() => In("en", () =>
{
Assert.Equal(["Nov 2025", "Dec 2025", "Jan 2026", "Feb 2026"], AnalysisChartPlan.BucketLabels(Buckets(D(2025, 11, 1), D(2026, 2, 28))));
Assert.Equal(["Oct", "Nov", "Dec"], AnalysisChartPlan.BucketLabels(Buckets(D(2025, 10, 1), D(2025, 12, 31))));
Assert.Equal(["2024", "2025"], AnalysisChartPlan.BucketLabels(Buckets(D(2024, 1, 1), D(2025, 12, 31), BucketSize.Year)));
var days = AnalysisChartPlan.BucketLabels(Buckets(D(2025, 12, 30), D(2026, 1, 2), BucketSize.Day));
Assert.Equal(["Dec 30, 2025", "Dec 31, 2025", "Jan 1, 2026", "Jan 2, 2026"], days);
});
[Fact]
public void Series_of_different_units_never_share_an_axis()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var costs = Priced(buckets, 100, 0.3).Lines[0].Buckets;
var plan = AnalysisChartPlan.Build(
buckets,
[
new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Available(2)]),
new AnalysisChartSeries("m2", "Wasser", "m³", [Available(3), Available(4)]),
new AnalysisChartSeries("m3", "Auto", "kWh", [Available(5), Available(6)]),
AnalysisChartSeries.ForCost("cost", "Cost", "EUR", costs),
],
Dark);
Assert.Equal(["kWh", "m³", "€"], plan.Panels.Select(p => p.Unit));
Assert.Equal(["Haus", "Auto"], plan.Panels[0].Series.Select(s => s.Name));
// Colour follows the series in the order given, across panels.
Assert.Equal([Dark.SeriesColor(0), Dark.SeriesColor(2)], plan.Panels[0].Series.Select(s => s.Color));
Assert.Equal(Dark.SeriesColor(1), plan.Panels[1].Series[0].Color);
}
[Fact]
public void Two_meters_with_one_name_stay_two_series()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 1, 31));
var plan = AnalysisChartPlan.Build(
buckets,
[new AnalysisChartSeries("m1", "Keller", "kWh", [Available(1)]), new AnalysisChartSeries("m2", "Keller", "kWh", [Available(2)])],
Dark);
Assert.Equal(["Keller", "Keller (2)"], plan.Panels[0].Series.Select(s => s.Name));
}
[Fact]
public void Costs_without_a_price_are_gaps_with_the_reason() => In("en", () =>
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var gap = Priced(buckets, 100, 0.25, priceFrom: D(2026, 2, 1)).Lines[0].Buckets;
var none = Priced(buckets, 100, null).Lines[0].Buckets;
var plan = AnalysisChartPlan.Build(
buckets,
[AnalysisChartSeries.ForCost("a", "Strom", "EUR", gap), AnalysisChartSeries.ForCost("b", "Wasser", "EUR", none)],
Dark);
var withGap = plan.Panels[0].Series[0].Points;
Assert.Equal([null, 25m, 25m], withGap.Select(p => p.Value));
Assert.Equal("— · Unavailable (tariff gap)", withGap[0].Tooltip);
Assert.Equal("25.00 €", withGap[1].Tooltip);
Assert.All(plan.Panels[0].Series[1].Points, p => Assert.Null(p.Value));
Assert.Contains("Not priced (no tariff)", plan.Panels[0].Series[1].Points[0].Tooltip, StringComparison.Ordinal);
});
[Fact]
public void The_baseline_is_a_real_zero_and_signed_values_get_a_zero_line()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 2, 28));
var signed = Options(buckets, [Available(-50), Available(30)]);
Assert.Null(signed.Yaxis[0].Min);
Assert.Null(signed.Yaxis[0].Max);
Assert.Equal(0, Assert.Single(signed.Annotations.Yaxis).Y);
var positive = Options(buckets, [Available(20), Available(30)]);
Assert.Equal(0, positive.Yaxis[0].Min);
Assert.Null(positive.Annotations);
var negative = Options(buckets, [Available(-20), Available(-30)]);
Assert.Equal(0, negative.Yaxis[0].Max);
}
[Fact]
public void Options_follow_the_theme_draw_straight_lines_and_do_not_animate()
{
var buckets = Buckets(D(2026, 1, 1), D(2026, 3, 31));
var series = new AnalysisChartSeries("m1", "Haus", "kWh", [Available(1), Partial(2), Available(3)]) { Style = ChartSeriesStyle.Line };
var light = ChartPalette.For(isDark: false);
foreach (var palette in new[] { Dark, light })
{
var plan = AnalysisChartPlan.Build(buckets, [series], palette);
var options = AnalysisChartOptions.Build(plan.Panels[0], palette, CultureInfo.GetCultureInfo("de-DE"));
Assert.Equal("transparent", options.Chart.Background);
Assert.Equal(palette.IsDark ? Mode.Dark : Mode.Light, options.Theme.Mode);
Assert.Equal(palette.Text, options.Chart.ForeColor);
Assert.Equal(Curve.Straight, options.Stroke.Curve.Single());
Assert.False(options.Chart.Animations.Enabled);
Assert.Contains("\"de-DE\"", options.Yaxis[0].Labels.Formatter, StringComparison.Ordinal);
Assert.Contains("\" kWh\"", options.Yaxis[0].Labels.Formatter, StringComparison.Ordinal);
Assert.Equal(ChartFormatters.Tooltip, options.Tooltip.Y.Formatter);
// The partial point of the line is a hollow square: its shape marks it.
var marker = Assert.Single(options.Markers.Discrete);
Assert.Equal(1, marker.DataPointIndex);
Assert.Equal(MarkerShape.Square, marker.Shape);
}
Assert.NotEqual(Dark.Series[2], light.Series[2]);
}
[Fact]
public void Formatter_strings_cannot_be_broken_out_of()
{
var formatter = ChartFormatters.Axis("m\"3", CultureInfo.GetCultureInfo("en-US"));
Assert.DoesNotContain("m\"3", formatter, StringComparison.Ordinal);
Assert.DoesNotContain("", formatter, StringComparison.Ordinal);
Assert.Contains("\"en-US\"", formatter, StringComparison.Ordinal);
Assert.Equal("\"\\u20AC\"", ChartFormatters.Literal("€"));
Assert.StartsWith("function (value, opts)", ChartFormatters.Tooltip, StringComparison.Ordinal);
Assert.Contains("extra.text", ChartFormatters.Tooltip, StringComparison.Ordinal);
}
[Fact]
public void The_palette_comes_from_the_theme_as_plain_colours()
{
foreach (var palette in new[] { ChartPalette.For(true), ChartPalette.For(false) })
{
Assert.Equal(6, palette.Series.Count);
Assert.All(palette.Series, c => Assert.Matches("^#[0-9A-F]{6}$", c));
Assert.Equal(palette.Series[0], palette.SeriesColor(6));
Assert.StartsWith("rgba(", palette.Text, StringComparison.Ordinal);
}
Assert.Equal("#14B8A6", ChartPalette.For(true).Series[0]);
Assert.Equal("rgba(20,184,166,0.45)", ChartPalette.WithAlpha("#14B8A6", 0.45));
Assert.Equal("rgba(1,2,3,0.5)", ChartPalette.WithAlpha("rgba(1,2,3,0.5)", 0.2));
}
private static ApexChartOptions Options(IReadOnlyList buckets, BucketValue[] values)
{
var plan = AnalysisChartPlan.Build(buckets, [new AnalysisChartSeries("m1", "Netz", "kWh", values)], Dark);
return AnalysisChartOptions.Build(plan.Panels[0], Dark, CultureInfo.InvariantCulture);
}
}