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