diff --git a/.github/workflows/build-core-lib.yml b/.github/workflows/build-core-lib.yml
index 9941182eaf..8beba892fa 100644
--- a/.github/workflows/build-core-lib.yml
+++ b/.github/workflows/build-core-lib.yml
@@ -32,7 +32,8 @@ env:
./src/Templates/Microsoft.FluentUI.AspNetCore.Templates.csproj
TESTS: >
./tests/Core/Components.Tests.csproj
- ./tests/Tools/McpServer.Tests/Microsoft.FluentUI.AspNetCore.McpServer.Tests.csproj
+ ./tests/Charts/Components.Charts.Tests.csproj
+ ./tests/McpServer/McpServer.Tests.csproj
INTEGRATION_TEST: "" # DISABLING: ./tests/Integration/Components.IntegrationTests.csproj
MIN_COVERAGE: "98"
diff --git a/Directory.Build.props b/Directory.Build.props
index cd621d46fa..d44505bb1c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -54,7 +54,10 @@
<_Parameter1>Microsoft.FluentUI.AspNetCore.Components.Tests
-
+
+ <_Parameter1>Microsoft.FluentUI.AspNetCore.Components.Charts.Tests
+
+
<_Parameter1>FluentUI.Demo.DocViewer.Tests
diff --git a/Microsoft.FluentUI-v5.slnx b/Microsoft.FluentUI-v5.slnx
index 1783c4189c..a0fce7a553 100644
--- a/Microsoft.FluentUI-v5.slnx
+++ b/Microsoft.FluentUI-v5.slnx
@@ -43,12 +43,11 @@
+
-
-
-
-
-
+
+
+
diff --git a/eng/pipelines/build-all-lib.yml b/eng/pipelines/build-all-lib.yml
index 01d0264151..ed7885acc6 100644
--- a/eng/pipelines/build-all-lib.yml
+++ b/eng/pipelines/build-all-lib.yml
@@ -24,7 +24,8 @@ parameters:
- name: Tests # List of Unit-Test projects to run
default: |
**/Components.Tests.csproj
- **/Microsoft.FluentUI.AspNetCore.McpServer.Tests.csproj
+ **/Components.Charts.Tests.csproj
+ **/McpServer.Tests.csproj
- name: NugetPackageVersion # NuGet package version to use (overrides computed version)
type: string
diff --git a/eng/pipelines/build-core-lib.yml b/eng/pipelines/build-core-lib.yml
index 85960ab5dc..d4c9181045 100644
--- a/eng/pipelines/build-core-lib.yml
+++ b/eng/pipelines/build-core-lib.yml
@@ -53,7 +53,8 @@ parameters:
- name: Tests # List of Unit-Test projects to run
default: |
**/Components.Tests.csproj
- **/Microsoft.FluentUI.AspNetCore.McpServer.Tests.csproj
+ **/Components.Charts.Tests.csproj
+ **/McpServer.Tests.csproj
variables:
- template: /eng/pipelines/version.yml@self
diff --git a/src/Charts/Charts/FluentCartesianChartBase.cs b/src/Charts/Charts/FluentCartesianChartBase.cs
index bd64f6a2ae..1590b88236 100644
--- a/src/Charts/Charts/FluentCartesianChartBase.cs
+++ b/src/Charts/Charts/FluentCartesianChartBase.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components;
@@ -209,6 +210,7 @@ protected override TooltipContext BuildTooltipContext(
[JsonSerializable(typeof(IDictionary))]
[JsonSerializable(typeof(Dictionary))]
[JsonSourceGenerationOptions(WriteIndented = false)]
+[ExcludeFromCodeCoverage(Justification = "This class is used for source-generated JSON serialization and does not contain any logic to be tested.")]
#pragma warning disable MA0048 // File name must match type name
internal partial class ChartJsonSerializerContext : JsonSerializerContext
#pragma warning restore MA0048 // File name must match type name
diff --git a/src/Charts/Infrastructure/ChartJson.cs b/src/Charts/Infrastructure/ChartJson.cs
index ac33b12693..0f97eeefb2 100644
--- a/src/Charts/Infrastructure/ChartJson.cs
+++ b/src/Charts/Infrastructure/ChartJson.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
@@ -9,6 +10,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
///
/// Provides shared source-generated JSON serialization helpers for chart payloads.
///
+[ExcludeFromCodeCoverage(Justification = "Thin convenience wrapper around source-generated JSON contexts; behavior is covered by serializer context tests.")]
public static class ChartJson
{
///
diff --git a/src/Charts/Models/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisSeries.cs b/src/Charts/Models/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisSeries.cs
deleted file mode 100644
index 479216738e..0000000000
--- a/src/Charts/Models/HorizontalBarChartWithAxis/HorizontalBarChartWithAxisSeries.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-// ------------------------------------------------------------------------
-// This file is licensed to you under the MIT License.
-// ------------------------------------------------------------------------
-
-using System.Text.Json.Serialization;
-
-namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
-
-///
-/// Represents one horizontal bar chart series in the data payload.
-///
-public sealed record HorizontalBarChartWithAxisSeries
-{
- ///
- /// Gets the optional title shown for the data series.
- ///
- [JsonPropertyName("chartSeriesTitle")]
- public string? ChartSeriesTitle { get; init; }
-
- ///
- /// Gets the collection of data points rendered within the series.
- ///
- [JsonPropertyName("chartData")]
- public IReadOnlyList ChartData { get; init; } = [];
-
- ///
- /// Gets the optional benchmark value used to render the benchmark indicator.
- ///
- [JsonPropertyName("benchmarkData")]
- public double? BenchmarkData { get; init; }
-
- ///
- /// Gets optional text displayed alongside the chart data for the series.
- ///
- [JsonPropertyName("chartDataText")]
- public string? ChartDataText { get; init; }
-}
diff --git a/src/Charts/Serialization/DataVizPaletteJsonConverter.cs b/src/Charts/Serialization/DataVizPaletteJsonConverter.cs
index c94d03f916..b94e54f59e 100644
--- a/src/Charts/Serialization/DataVizPaletteJsonConverter.cs
+++ b/src/Charts/Serialization/DataVizPaletteJsonConverter.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.FluentUI.AspNetCore.Components.Extensions;
@@ -13,6 +14,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
/// (e.g. "color5", "info") so the chart web components can
/// resolve it to an actual hex color at runtime.
///
+[ExcludeFromCodeCoverage(Justification = "Serialization glue with minimal behavior; covered by higher-level serialization tests.")]
internal sealed class DataVizPaletteJsonConverter : JsonConverter
{
///
diff --git a/src/Charts/Serialization/DonutChartDataJsonSerializerContext.cs b/src/Charts/Serialization/DonutChartDataJsonSerializerContext.cs
index 8e59c5aec4..6df877ca86 100644
--- a/src/Charts/Serialization/DonutChartDataJsonSerializerContext.cs
+++ b/src/Charts/Serialization/DonutChartDataJsonSerializerContext.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
@@ -11,6 +12,7 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
///
[JsonSerializable(typeof(DonutDataPoint))]
[JsonSerializable(typeof(IReadOnlyList))]
+[ExcludeFromCodeCoverage(Justification = "This class is used for source-generated JSON serialization and does not contain any logic to be tested.")]
internal sealed partial class DonutChartDataJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/src/Charts/Serialization/FunnelChartDataJsonSerializerContext.cs b/src/Charts/Serialization/FunnelChartDataJsonSerializerContext.cs
index cffc5d0af7..5415939539 100644
--- a/src/Charts/Serialization/FunnelChartDataJsonSerializerContext.cs
+++ b/src/Charts/Serialization/FunnelChartDataJsonSerializerContext.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
@@ -11,6 +12,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
///
[JsonSerializable(typeof(FunnelDataPoint))]
[JsonSerializable(typeof(IReadOnlyList))]
+[ExcludeFromCodeCoverage(Justification = "This class is used for source-generated JSON serialization and does not contain any logic to be tested.")]
+
internal sealed partial class FunnelChartDataJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/src/Charts/Serialization/GanttChartDataJsonSerializerContext.cs b/src/Charts/Serialization/GanttChartDataJsonSerializerContext.cs
index 4fef9db3a0..06fa1cc025 100644
--- a/src/Charts/Serialization/GanttChartDataJsonSerializerContext.cs
+++ b/src/Charts/Serialization/GanttChartDataJsonSerializerContext.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
@@ -14,6 +15,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
[JsonSerializable(typeof(GanttChartDataPoint))]
[JsonSerializable(typeof(CalloutAccessibilityData))]
[JsonSerializable(typeof(IReadOnlyList))]
+[ExcludeFromCodeCoverage(Justification = "This class is used for source-generated JSON serialization and does not contain any logic to be tested.")]
+
internal sealed partial class GanttChartDataJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/src/Charts/Serialization/HorizontalBarChartDataJsonSerializerContext.cs b/src/Charts/Serialization/HorizontalBarChartDataJsonSerializerContext.cs
index faeaa3d5b2..f6118fde34 100644
--- a/src/Charts/Serialization/HorizontalBarChartDataJsonSerializerContext.cs
+++ b/src/Charts/Serialization/HorizontalBarChartDataJsonSerializerContext.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
@@ -13,6 +14,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
[JsonSerializable(typeof(HorizontalBarChartDataPoint))]
[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(IReadOnlyList))]
+[ExcludeFromCodeCoverage(Justification = "This class is used for source-generated JSON serialization and does not contain any logic to be tested.")]
+
internal sealed partial class HorizontalBarChartDataJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/src/Charts/Serialization/HorizontalBarChartWithAxisDataJsonSerializerContext.cs b/src/Charts/Serialization/HorizontalBarChartWithAxisDataJsonSerializerContext.cs
index b19feaca4a..836e4de1a8 100644
--- a/src/Charts/Serialization/HorizontalBarChartWithAxisDataJsonSerializerContext.cs
+++ b/src/Charts/Serialization/HorizontalBarChartWithAxisDataJsonSerializerContext.cs
@@ -2,6 +2,7 @@
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
@@ -12,6 +13,8 @@ namespace Microsoft.FluentUI.AspNetCore.Components.Charts;
[JsonSerializable(typeof(CalloutAccessibilityData))]
[JsonSerializable(typeof(HorizontalBarChartWithAxisDataPoint))]
[JsonSerializable(typeof(IReadOnlyList))]
+[ExcludeFromCodeCoverage(Justification = "This class is used for source-generated JSON serialization and does not contain any logic to be tested.")]
+
internal sealed partial class HorizontalBarChartWithAxisDataJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/src/Tools/McpServer/Microsoft.FluentUI.AspNetCore.McpServer.csproj b/src/Tools/McpServer/Microsoft.FluentUI.AspNetCore.McpServer.csproj
index f66d36f3d2..dafd49e41d 100644
--- a/src/Tools/McpServer/Microsoft.FluentUI.AspNetCore.McpServer.csproj
+++ b/src/Tools/McpServer/Microsoft.FluentUI.AspNetCore.McpServer.csproj
@@ -49,7 +49,7 @@
-
+
diff --git a/tests/Charts/Charts/DonutChart/FluentDonutChartTests.FluentDonutChart_Default.verified.razor.html b/tests/Charts/Charts/DonutChart/FluentDonutChartTests.FluentDonutChart_Default.verified.razor.html
new file mode 100644
index 0000000000..5df8c8bad7
--- /dev/null
+++ b/tests/Charts/Charts/DonutChart/FluentDonutChartTests.FluentDonutChart_Default.verified.razor.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Charts/Charts/DonutChart/FluentDonutChartTests.razor b/tests/Charts/Charts/DonutChart/FluentDonutChartTests.razor
new file mode 100644
index 0000000000..2c3bc232b0
--- /dev/null
+++ b/tests/Charts/Charts/DonutChart/FluentDonutChartTests.razor
@@ -0,0 +1,315 @@
+@using Microsoft.FluentUI.AspNetCore.Components.Charts
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ private static readonly IReadOnlyList DefaultData =
+ [
+ new DonutDataPoint { Legend = "Category A", Data = 40 },
+ new DonutDataPoint { Legend = "Category B", Data = 30 },
+ new DonutDataPoint { Legend = "Category C", Data = 20 },
+ new DonutDataPoint { Legend = "Category D", Data = 10 },
+ ];
+
+ public FluentDonutChartTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ [Fact]
+ public void FluentDonutChart_Default()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ cut.Verify();
+ }
+
+ [Fact]
+ public void FluentDonutChart_ChartTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("chart-title");
+ Assert.Equal("My Donut Chart", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_ValueInsideDonut()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("value-inside-donut");
+ Assert.Equal("100 total", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_InnerRadius()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("inner-radius");
+ Assert.Equal("60", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_InnerRadius_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("inner-radius");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_ShowLabelsInPercent()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("show-labels-in-percent");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_HideTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("hide-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_HideTooltip_False()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("hide-tooltip");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_HideLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("hide-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_HideLegends()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("hide-legends");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_RoundedCorners()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("round-corners");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_AllowMultipleLegendSelection()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("allow-multiple-legend-selection");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_LegendListLabel()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("legend-list-label");
+ Assert.Equal("Sections", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_Width()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("width");
+ Assert.Equal("400px", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_Height()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("height");
+ Assert.Equal("300px", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_Culture()
+ {
+ // Arrange
+ var culture = new System.Globalization.CultureInfo("fr-FR");
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("culture");
+ Assert.Equal("fr-FR", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_Culture_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("culture");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_Class()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-donut-chart");
+ Assert.Contains("my-chart", element.GetAttribute("class") ?? string.Empty);
+ Assert.Contains("fluent-donut-chart", element.GetAttribute("class") ?? string.Empty);
+ }
+
+ [Fact]
+ public void FluentDonutChart_AdditionalAttributes()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-donut-chart").GetAttribute("aria-label");
+ Assert.Equal("Sales breakdown", attribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_DataSerializedAsJson()
+ {
+ // Arrange
+ var data = new List
+ {
+ new DonutDataPoint { Legend = "Alpha", Data = 75 },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-donut-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("Alpha", dataAttribute);
+ Assert.Contains("75", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_DataPoint_WithColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new DonutDataPoint { Legend = "Segment", Data = 50, Color = DataVizPalette.Color1 },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-donut-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("color1", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_DataPoint_WithCustomColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new DonutDataPoint { Legend = "Custom", Data = 50, Color = DataVizPalette.Custom, CustomColor = "#FF5733" },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-donut-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("#FF5733", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentDonutChart_TooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend: @ctx.YValue
+
+
+ );
+
+ // Assert — portal div is present when TooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentDonutChart_EmptyData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-donut-chart");
+ Assert.NotNull(element);
+ }
+}
+
diff --git a/tests/Charts/Charts/FluentChartBaseTests.razor b/tests/Charts/Charts/FluentChartBaseTests.razor
new file mode 100644
index 0000000000..ae22b6f71f
--- /dev/null
+++ b/tests/Charts/Charts/FluentChartBaseTests.razor
@@ -0,0 +1,408 @@
+@using Microsoft.FluentUI.AspNetCore.Components.Charts
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ private static readonly IReadOnlyList DefaultDonutData =
+ [
+ new DonutDataPoint { Legend = "A", Data = 50 },
+ new DonutDataPoint { Legend = "B", Data = 50 },
+ ];
+
+ private static readonly IReadOnlyList DefaultGanttData =
+ [
+ new GanttChartDataPoint
+ {
+ X = new GanttChartXRange
+ {
+ Start = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ End = new DateTime(2024, 1, 31, 0, 0, 0, DateTimeKind.Utc),
+ },
+ Y = "Task A",
+ Legend = "Phase 1",
+ },
+ ];
+
+ public FluentChartBaseTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ // ── BuildTooltipContext (base – FluentChartBase) ─────────────────────────
+
+ // Access the protected method through a thin concrete subclass.
+ private sealed class TestableDonutChart : FluentDonutChart
+ {
+ public TestableDonutChart(LibraryConfiguration cfg) : base(cfg) { }
+
+ public TooltipContext CallBuild(
+ string? legend, string? yValue, string? xValue,
+ string? color, string? rawJson,
+ string? xStart, string? xEnd)
+ => BuildTooltipContext(legend, yValue, xValue, color, rawJson, xStart, xEnd);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_MapsAllStringFields()
+ {
+ var chart = new TestableDonutChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild("My Legend", "42", "x-val", null, """{"data":42}""", null, null);
+
+ Assert.Equal("My Legend", ctx.Legend);
+ Assert.Equal("42", ctx.YValue);
+ Assert.Equal("x-val", ctx.XValue);
+ Assert.Equal("""{"data":42}""", ctx.RawJson);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_RecognizedPaletteToken_SetsColorAndNullsCustomColor()
+ {
+ var chart = new TestableDonutChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, "color3", null, null, null);
+
+ Assert.Equal(DataVizPalette.Color3, ctx.Color);
+ Assert.Null(ctx.CustomColor);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_UnrecognizedColorString_SetsNullColorAndCustomColor()
+ {
+ var chart = new TestableDonutChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, "#FF5733", null, null, null);
+
+ Assert.Null(ctx.Color);
+ Assert.Equal("#FF5733", ctx.CustomColor);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_NullColor_LeavesColorAndCustomColorNull()
+ {
+ var chart = new TestableDonutChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, null, null, null, null);
+
+ Assert.Null(ctx.Color);
+ Assert.Null(ctx.CustomColor);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_AllNullArguments_ReturnsEmptyContext()
+ {
+ var chart = new TestableDonutChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, null, null, null, null);
+
+ Assert.Null(ctx.Legend);
+ Assert.Null(ctx.YValue);
+ Assert.Null(ctx.XValue);
+ Assert.Null(ctx.Color);
+ Assert.Null(ctx.CustomColor);
+ Assert.Null(ctx.RawJson);
+ }
+
+ // ── BuildTooltipContext override – FluentCartesianChartBase ──────────────
+
+ private sealed class TestableGanttChart : FluentGanttChart
+ {
+ public TestableGanttChart(LibraryConfiguration cfg) : base(cfg) { }
+
+ public TooltipContext CallBuild(
+ string? legend, string? yValue, string? xValue,
+ string? color, string? rawJson,
+ string? xStart, string? xEnd)
+ => BuildTooltipContext(legend, yValue, xValue, color, rawJson, xStart, xEnd);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_ReturnsCartesianTooltipContext()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild("Task A", "30", null, "color1",
+ null, "2024-01-01T00:00:00.000Z", "2024-01-31T00:00:00.000Z");
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Equal("Task A", cartesian.Legend);
+ Assert.Equal(DataVizPalette.Color1, cartesian.Color);
+ Assert.Equal("2024-01-01T00:00:00.000Z", cartesian.XStart);
+ Assert.Equal("2024-01-31T00:00:00.000Z", cartesian.XEnd);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_CustomColor_SetsNullColorAndCustomColor()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, "#0099BC", null, null, null);
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Null(cartesian.Color);
+ Assert.Equal("#0099BC", cartesian.CustomColor);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_MapsAllStringFields()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(
+ "Task B", "Phase 2", "x-axis-val", null,
+ """{"extra":true}""",
+ "2024-03-01T00:00:00.000Z", "2024-03-31T00:00:00.000Z");
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Equal("Task B", cartesian.Legend);
+ Assert.Equal("Phase 2", cartesian.YValue);
+ Assert.Equal("x-axis-val", cartesian.XValue);
+ Assert.Equal("""{"extra":true}""", cartesian.RawJson);
+ Assert.Equal("2024-03-01T00:00:00.000Z", cartesian.XStart);
+ Assert.Equal("2024-03-31T00:00:00.000Z", cartesian.XEnd);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_AllNullArguments_ReturnsEmptyCartesianContext()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, null, null, null, null);
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Null(cartesian.Legend);
+ Assert.Null(cartesian.YValue);
+ Assert.Null(cartesian.XValue);
+ Assert.Null(cartesian.Color);
+ Assert.Null(cartesian.CustomColor);
+ Assert.Null(cartesian.RawJson);
+ Assert.Null(cartesian.XStart);
+ Assert.Null(cartesian.XEnd);
+ }
+
+ [Theory]
+ [InlineData("color1", DataVizPalette.Color1)]
+ [InlineData("color5", DataVizPalette.Color5)]
+ [InlineData("color10", DataVizPalette.Color10)]
+ [InlineData("info", DataVizPalette.Info)]
+ [InlineData("success", DataVizPalette.Success)]
+ [InlineData("warning", DataVizPalette.Warning)]
+ [InlineData("error", DataVizPalette.Error)]
+ public void BuildTooltipContext_CartesianOverride_RecognizedPaletteToken_SetsColorAndNullsCustomColor(
+ string token, DataVizPalette expected)
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, token, null, null, null);
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Equal(expected, cartesian.Color);
+ Assert.Null(cartesian.CustomColor);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_NullColor_LeavesColorAndCustomColorNull()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, null, null, null, null);
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Null(cartesian.Color);
+ Assert.Null(cartesian.CustomColor);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_XStartAndXEnd_PopulatedIndependentlyOfColor()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, null, null,
+ "2024-06-01T00:00:00.000Z", "2024-06-30T00:00:00.000Z");
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Equal("2024-06-01T00:00:00.000Z", cartesian.XStart);
+ Assert.Equal("2024-06-30T00:00:00.000Z", cartesian.XEnd);
+ Assert.Null(cartesian.Color);
+ }
+
+ [Fact]
+ public void BuildTooltipContext_CartesianOverride_CssVariableCustomColor_IsPreserved()
+ {
+ var chart = new TestableGanttChart(new LibraryConfiguration());
+
+ var ctx = chart.CallBuild(null, null, null, "var(--custom-brand)", null, null, null);
+
+ var cartesian = Assert.IsType(ctx);
+ Assert.Null(cartesian.Color);
+ Assert.Equal("var(--custom-brand)", cartesian.CustomColor);
+ }
+
+
+
+ [Fact]
+ public async Task UpdateTooltipContextAsync_UpdatesTooltipContext()
+ {
+ // Arrange — render a chart so the component has a live Blazor renderer
+ var cut = Render(@);
+
+ // Act — call the JS-invokable method through bUnit's InvokeAsync dispatcher
+ await cut.InvokeAsync(() =>
+ cut.Instance.UpdateTooltipContextAsync(
+ "Category A", "40", null, "color5", """{"data":40}""", null, null));
+
+ // Assert
+ var ctx = cut.Instance._tooltipContext;
+ Assert.Equal("Category A", ctx.Legend);
+ Assert.Equal("40", ctx.YValue);
+ Assert.Equal(DataVizPalette.Color5, ctx.Color);
+ Assert.Null(ctx.CustomColor);
+ Assert.Equal("""{"data":40}""", ctx.RawJson);
+ }
+
+ [Fact]
+ public async Task UpdateTooltipContextAsync_WithCustomColor_SetsCustomColor()
+ {
+ var cut = Render(@);
+
+ await cut.InvokeAsync(() =>
+ cut.Instance.UpdateTooltipContextAsync(
+ "Custom", "99", null, "#A23B72", null, null, null));
+
+ var ctx = cut.Instance._tooltipContext;
+ Assert.Null(ctx.Color);
+ Assert.Equal("#A23B72", ctx.CustomColor);
+ }
+
+ [Fact]
+ public async Task UpdateTooltipContextAsync_AllNulls_ResetsContext()
+ {
+ var cut = Render(@);
+
+ // Prime the context with a value first
+ await cut.InvokeAsync(() =>
+ cut.Instance.UpdateTooltipContextAsync(
+ "Old", "1", null, "color1", null, null, null));
+
+ // Reset with all nulls
+ await cut.InvokeAsync(() =>
+ cut.Instance.UpdateTooltipContextAsync(null, null, null, null, null, null, null));
+
+ var ctx = cut.Instance._tooltipContext;
+ Assert.Null(ctx.Legend);
+ Assert.Null(ctx.YValue);
+ Assert.Null(ctx.Color);
+ Assert.Null(ctx.CustomColor);
+ }
+
+ [Fact]
+ public async Task UpdateTooltipContextAsync_GanttChart_SetsCartesianContext()
+ {
+ var cut = Render(@);
+
+ await cut.InvokeAsync(() =>
+ cut.Instance.UpdateTooltipContextAsync(
+ "Task A", "Phase 1", null, "color2", null,
+ "2024-01-01T00:00:00.000Z", "2024-01-31T00:00:00.000Z"));
+
+ var ctx = Assert.IsType(cut.Instance._tooltipContext);
+ Assert.Equal("Task A", ctx.Legend);
+ Assert.Equal(DataVizPalette.Color2, ctx.Color);
+ Assert.Equal("2024-01-01T00:00:00.000Z", ctx.XStart);
+ Assert.Equal("2024-01-31T00:00:00.000Z", ctx.XEnd);
+ }
+
+ // ── DisposeAsync ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DisposeAsync_NoJsModule_CompletesWithoutThrowing()
+ {
+ // A chart that never rendered its tooltip bridge (no TooltipTemplate set)
+ // has _jsModule == null. DisposeAsync should be a no-op.
+ var cut = Render(@);
+
+ var exception = await Record.ExceptionAsync(() => cut.Instance.DisposeAsync().AsTask());
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_CalledTwice_DoesNotThrow()
+ {
+ var cut = Render(@);
+
+ await cut.Instance.DisposeAsync();
+ var exception = await Record.ExceptionAsync(() => cut.Instance.DisposeAsync().AsTask());
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_WithJsModule_InvokesDestroyAndDisposesModule()
+ {
+ // Arrange — register a JS module handler so OnAfterRenderAsync initialises the bridge
+ var module = JSInterop.SetupModule(
+ matcher => matcher.Arguments.Any(arg => arg?.ToString()?.EndsWith("chart-tooltip-bridge.js") == true));
+ module.SetupVoid("initTooltipBridge", _ => true).SetVoidResult();
+ module.SetupVoid("destroyTooltipBridge", _ => true).SetVoidResult();
+
+ // Render with a TooltipTemplate so HasTooltipTemplate == true and the bridge is initialized
+ var cut = Render(
+ @
+
+ @ctx.Legend
+
+
+ );
+
+ var exception = await Record.ExceptionAsync(() => cut.Instance.DisposeAsync().AsTask());
+
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_WhenDestroyThrowsJsDisconnectedException_IsSwallowed()
+ {
+ // Arrange — trigger the JSDisconnectedException catch path in DisposeAsync
+ var module = JSInterop.SetupModule(
+ matcher => matcher.Arguments.Any(arg => arg?.ToString()?.EndsWith("chart-tooltip-bridge.js") == true));
+ module.SetupVoid("initTooltipBridge", _ => true).SetVoidResult();
+ module.SetupVoid("destroyTooltipBridge", _ => true).SetException(new JSDisconnectedException("Disconnected"));
+
+ var cut = Render(
+ @
+
+ @ctx.Legend
+
+
+ );
+
+ // Assert — JSDisconnectedException is explicitly caught and ignored
+ var exception = await Record.ExceptionAsync(() => cut.Instance.DisposeAsync().AsTask());
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public async Task DisposeAsync_WhenDestroyThrowsOperationCanceledException_IsSwallowed()
+ {
+ // Arrange — trigger the OperationCanceledException catch path in DisposeAsync
+ var module = JSInterop.SetupModule(
+ matcher => matcher.Arguments.Any(arg => arg?.ToString()?.EndsWith("chart-tooltip-bridge.js") == true));
+ module.SetupVoid("initTooltipBridge", _ => true).SetVoidResult();
+ module.SetupVoid("destroyTooltipBridge", _ => true).SetException(new OperationCanceledException("Cancelled"));
+
+ var cut = Render(
+ @
+
+ @ctx.Legend
+
+
+ );
+
+ // Assert — OperationCanceledException is explicitly caught and ignored
+ var exception = await Record.ExceptionAsync(() => cut.Instance.DisposeAsync().AsTask());
+ Assert.Null(exception);
+ }
+}
diff --git a/tests/Charts/Charts/FunnelChart/FluentFunnelChartTests.FluentFunnelChart_Default.verified.razor.html b/tests/Charts/Charts/FunnelChart/FluentFunnelChartTests.FluentFunnelChart_Default.verified.razor.html
new file mode 100644
index 0000000000..6f8491eeda
--- /dev/null
+++ b/tests/Charts/Charts/FunnelChart/FluentFunnelChartTests.FluentFunnelChart_Default.verified.razor.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Charts/Charts/FunnelChart/FluentFunnelChartTests.razor b/tests/Charts/Charts/FunnelChart/FluentFunnelChartTests.razor
new file mode 100644
index 0000000000..f8c40f861f
--- /dev/null
+++ b/tests/Charts/Charts/FunnelChart/FluentFunnelChartTests.razor
@@ -0,0 +1,283 @@
+@using Microsoft.FluentUI.AspNetCore.Components.Charts
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ private static readonly IReadOnlyList DefaultData =
+ [
+ new FunnelDataPoint { Stage = "Awareness", Value = 500 },
+ new FunnelDataPoint { Stage = "Interest", Value = 400 },
+ new FunnelDataPoint { Stage = "Evaluation", Value = 250 },
+ new FunnelDataPoint { Stage = "Decision", Value = 100 },
+ ];
+
+ public FluentFunnelChartTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ [Fact]
+ public void FluentFunnelChart_Default()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ cut.Verify();
+ }
+
+ [Fact]
+ public void FluentFunnelChart_ChartTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("chart-title");
+ Assert.Equal("Sales Funnel", attribute);
+ }
+
+ [Theory]
+ [InlineData(Orientation.Horizontal, "horizontal")]
+ [InlineData(Orientation.Vertical, "vertical")]
+ public void FluentFunnelChart_Orientation(Orientation value, string expectedValue)
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("orientation");
+ Assert.Equal(expectedValue, attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_HideTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("hide-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_HideTooltip_False()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("hide-tooltip");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_HideLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("hide-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_HideLegends()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("hide-legends");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_RoundedCorners()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("round-corners");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_LegendListLabel()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("legend-list-label");
+ Assert.Equal("Stages", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_AllowMultipleLegendSelection()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("allow-multiple-legend-selection");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_Width()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("width");
+ Assert.Equal("600px", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_Height()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("height");
+ Assert.Equal("400px", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_Culture()
+ {
+ // Arrange
+ var culture = new System.Globalization.CultureInfo("de-DE");
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("culture");
+ Assert.Equal("de-DE", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_Culture_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("culture");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_Class()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-funnel-chart");
+ Assert.Contains("my-funnel", element.GetAttribute("class") ?? string.Empty);
+ Assert.Contains("fluent-funnel-chart", element.GetAttribute("class") ?? string.Empty);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_AdditionalAttributes()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-funnel-chart").GetAttribute("aria-label");
+ Assert.Equal("Conversion funnel", attribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_DataSerializedAsJson()
+ {
+ // Arrange
+ var data = new List
+ {
+ new FunnelDataPoint { Stage = "Step 1", Value = 100 },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-funnel-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("Step 1", dataAttribute);
+ Assert.Contains("100", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_DataPoint_WithColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new FunnelDataPoint { Stage = "Top", Value = 200, Color = DataVizPalette.Color2 },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-funnel-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("color2", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_DataPoint_WithCustomColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new FunnelDataPoint { Stage = "Top", Value = 200, Color = DataVizPalette.Custom, CustomColor = "#A23B72" },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-funnel-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("#A23B72", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentFunnelChart_TooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend: @ctx.YValue
+
+
+ );
+
+ // Assert — portal div is present when TooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentFunnelChart_EmptyData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-funnel-chart");
+ Assert.NotNull(element);
+ }
+}
diff --git a/tests/Charts/Charts/GanttChart/FluentGanttChartTests.FluentGanttChart_Default.verified.razor.html b/tests/Charts/Charts/GanttChart/FluentGanttChartTests.FluentGanttChart_Default.verified.razor.html
new file mode 100644
index 0000000000..a4a6be72b7
--- /dev/null
+++ b/tests/Charts/Charts/GanttChart/FluentGanttChartTests.FluentGanttChart_Default.verified.razor.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Charts/Charts/GanttChart/FluentGanttChartTests.razor b/tests/Charts/Charts/GanttChart/FluentGanttChartTests.razor
new file mode 100644
index 0000000000..f2968851a7
--- /dev/null
+++ b/tests/Charts/Charts/GanttChart/FluentGanttChartTests.razor
@@ -0,0 +1,542 @@
+@using Microsoft.FluentUI.AspNetCore.Components.Charts
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ private static readonly DateTime Start1 = new DateTime(2024, 1, 1);
+ private static readonly DateTime End1 = new DateTime(2024, 1, 15);
+ private static readonly DateTime Start2 = new DateTime(2024, 1, 10);
+ private static readonly DateTime End2 = new DateTime(2024, 1, 25);
+
+ private static readonly IReadOnlyList DefaultData =
+ [
+ new GanttChartDataPoint
+ {
+ X = new GanttChartXRange { Start = Start1, End = End1 },
+ Y = "Task A",
+ Legend = "Phase 1",
+ },
+ new GanttChartDataPoint
+ {
+ X = new GanttChartXRange { Start = Start2, End = End2 },
+ Y = "Task B",
+ Legend = "Phase 2",
+ },
+ ];
+
+ public FluentGanttChartTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ [Fact]
+ public void FluentGanttChart_Default()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ cut.Verify();
+ }
+
+ [Fact]
+ public void FluentGanttChart_ChartTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("chart-title");
+ Assert.Equal("Project Timeline", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_HideTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("hide-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_HideTooltip_False()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("hide-tooltip");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_HideLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("hide-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_HideLegends()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("hide-legends");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_RoundedCorners()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("round-corners");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_UseSingleColor()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("use-single-color");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_EnableGradient()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("enable-gradient");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_ShowYAxisLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("show-y-axis-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_ShowYAxisLabelsTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("show-y-axis-labels-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_AllowMultipleLegendSelection()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("allow-multiple-legend-selection");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_LegendListLabel()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("legend-list-label");
+ Assert.Equal("Tasks", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_BarHeight()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("bar-height");
+ Assert.Equal("32", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_XAxisTickCount()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("x-axis-tick-count");
+ Assert.Equal("5", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_YAxisPadding()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("y-axis-padding");
+ Assert.Equal("1", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_XAxisTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("x-axis-title");
+ Assert.Equal("Timeline", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_YAxisTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("y-axis-title");
+ Assert.Equal("Tasks", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_SupportNegativeData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("support-negative-data");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_RoundedTicks()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("rounded-ticks");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_WrapXAxisLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("wrap-x-axis-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_RotateXAxisLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("rotate-x-axis-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_StrokeWidth()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("stroke-width");
+ Assert.Equal("2", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_Culture()
+ {
+ // Arrange
+ var culture = new System.Globalization.CultureInfo("en-GB");
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("culture");
+ Assert.Equal("en-GB", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_Culture_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("culture");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_Width()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("width");
+ Assert.Equal("800px", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_Height()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("height");
+ Assert.Equal("500px", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_Class()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-gantt-chart");
+ Assert.Contains("my-gantt", element.GetAttribute("class") ?? string.Empty);
+ Assert.Contains("fluent-gantt-chart", element.GetAttribute("class") ?? string.Empty);
+ }
+
+ [Fact]
+ public void FluentGanttChart_AdditionalAttributes()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-gantt-chart").GetAttribute("aria-label");
+ Assert.Equal("Project schedule", attribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_DataSerializedAsJson()
+ {
+ // Arrange
+ var data = new List
+ {
+ new GanttChartDataPoint
+ {
+ X = new GanttChartXRange { Start = Start1, End = End1 },
+ Y = "My Task",
+ Legend = "Sprint 1",
+ },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-gantt-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("My Task", dataAttribute);
+ Assert.Contains("Sprint 1", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_DataPoint_WithColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new GanttChartDataPoint
+ {
+ X = new GanttChartXRange { Start = Start1, End = End1 },
+ Y = "Task A",
+ Legend = "Phase 1",
+ Color = DataVizPalette.Color2,
+ },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-gantt-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("color2", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_DataPoint_WithCustomColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new GanttChartDataPoint
+ {
+ X = new GanttChartXRange { Start = Start1, End = End1 },
+ Y = "Task A",
+ Legend = "Phase 1",
+ Color = DataVizPalette.Custom,
+ CustomColor = "#C19A6B",
+ },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-gantt-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("#C19A6B", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentGanttChart_TooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend: @ctx.YValue
+
+
+ );
+
+ // Assert — portal div is present when TooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentGanttChart_CartesianTooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend: @ctx.XStart – @ctx.XEnd
+
+
+ );
+
+ // Assert — portal div is present when CartesianTooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentGanttChart_EmptyData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-gantt-chart");
+ Assert.NotNull(element);
+ }
+
+ // TickValuesJson is internal override, so a thin subclass exposes it for unit testing.
+ private sealed class TestableFluentGanttChart : FluentGanttChart
+ {
+ public TestableFluentGanttChart(LibraryConfiguration configuration) : base(configuration) { }
+ public string? TickValuesJsonPublic => TickValuesJson;
+ }
+
+ [Fact]
+ public void FluentGanttChart_TickValuesJson_Null_WhenNeitherTickValuesNorDateTickValuesSet()
+ {
+ // Arrange
+#pragma warning disable BL0005
+ var chart = new TestableFluentGanttChart(new LibraryConfiguration());
+#pragma warning restore BL0005
+
+ // Assert
+ Assert.Null(chart.TickValuesJsonPublic);
+ }
+
+ [Fact]
+ public void FluentGanttChart_TickValuesJson_SerializesNumericTickValues()
+ {
+ // Arrange
+#pragma warning disable BL0005
+ var chart = new TestableFluentGanttChart(new LibraryConfiguration())
+ {
+ TickValues = [100.0, 200.0, 300.0],
+ };
+#pragma warning restore BL0005
+
+ // Assert
+ Assert.Equal("[100,200,300]", chart.TickValuesJsonPublic);
+ }
+
+ [Fact]
+ public void FluentGanttChart_TickValuesJson_SerializesDateTickValuesAsUnixMilliseconds()
+ {
+ // Arrange — use a fixed UTC date so the assertion is timezone-independent
+ var date = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc);
+ var expectedMs = new DateTimeOffset(date).ToUnixTimeMilliseconds();
+
+#pragma warning disable BL0005
+ var chart = new TestableFluentGanttChart(new LibraryConfiguration())
+ {
+ DateTickValues = [date],
+ };
+#pragma warning restore BL0005
+
+ // Assert
+ Assert.Equal($"[{expectedMs}]", chart.TickValuesJsonPublic);
+ }
+
+ [Fact]
+ public void FluentGanttChart_TickValuesJson_DateTickValues_TakesPriorityOverTickValues()
+ {
+ // Arrange — when both are set, DateTickValues wins (overriding base.TickValuesJson)
+ var date = new DateTime(2024, 3, 1, 0, 0, 0, DateTimeKind.Utc);
+ var expectedMs = new DateTimeOffset(date).ToUnixTimeMilliseconds();
+
+#pragma warning disable BL0005
+ var chart = new TestableFluentGanttChart(new LibraryConfiguration())
+ {
+ TickValues = [999.0],
+ DateTickValues = [date],
+ };
+#pragma warning restore BL0005
+
+ // Assert — the JSON should contain the date timestamp, not the numeric tick value
+ Assert.Equal($"[{expectedMs}]", chart.TickValuesJsonPublic);
+ }
+}
diff --git a/tests/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChartTests.FluentHorizontalBarChart_Default.verified.razor.html b/tests/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChartTests.FluentHorizontalBarChart_Default.verified.razor.html
new file mode 100644
index 0000000000..06aff14c71
--- /dev/null
+++ b/tests/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChartTests.FluentHorizontalBarChart_Default.verified.razor.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChartTests.razor b/tests/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChartTests.razor
new file mode 100644
index 0000000000..2d487d08ce
--- /dev/null
+++ b/tests/Charts/Charts/HorizontalBarChart/FluentHorizontalBarChartTests.razor
@@ -0,0 +1,388 @@
+@using Microsoft.FluentUI.AspNetCore.Components.Charts
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ private static readonly IReadOnlyList DefaultData =
+ [
+ new HorizontalBarChartSeries
+ {
+ ChartSeriesTitle = "Q1",
+ ChartData =
+ [
+ new HorizontalBarChartDataPoint { Legend = "Product A", Data = 60 },
+ new HorizontalBarChartDataPoint { Legend = "Product B", Data = 40 },
+ ],
+ },
+ new HorizontalBarChartSeries
+ {
+ ChartSeriesTitle = "Q2",
+ ChartData =
+ [
+ new HorizontalBarChartDataPoint { Legend = "Product A", Data = 75 },
+ new HorizontalBarChartDataPoint { Legend = "Product B", Data = 55 },
+ ],
+ },
+ ];
+
+ public FluentHorizontalBarChartTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Default()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ cut.Verify();
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_ChartTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("chart-title");
+ Assert.Equal("Revenue by Product", attribute);
+ }
+
+ [Theory]
+ [InlineData(HorizontalBarChartVariant.PartToWhole, "part-to-whole")]
+ [InlineData(HorizontalBarChartVariant.AbsoluteScale, "absolute-scale")]
+ public void FluentHorizontalBarChart_Variant(HorizontalBarChartVariant value, string expectedValue)
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("variant");
+ Assert.Equal(expectedValue, attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Variant_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("variant");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HideRatio()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("hide-ratio");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HideTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("hide-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HideTooltip_False()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("hide-tooltip");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HideLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("hide-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HideLegends()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("hide-legends");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_RoundedCorners()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("round-corners");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_EnableGradient()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("enable-gradient");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_AllowMultipleLegendSelection()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("allow-multiple-legend-selection");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_LegendListLabel()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("legend-list-label");
+ Assert.Equal("Products", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_ChartDataMode()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("chart-data-mode");
+ Assert.Equal("fraction", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Width()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("width");
+ Assert.Equal("500px", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Height()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("height");
+ Assert.Equal("300px", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Culture()
+ {
+ // Arrange
+ var culture = new System.Globalization.CultureInfo("nl-NL");
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("culture");
+ Assert.Equal("nl-NL", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Culture_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("culture");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_Class()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-horizontal-bar-chart");
+ Assert.Contains("my-bar-chart", element.GetAttribute("class") ?? string.Empty);
+ Assert.Contains("fluent-horizontal-bar-chart", element.GetAttribute("class") ?? string.Empty);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_AdditionalAttributes()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("aria-label");
+ Assert.Equal("Quarterly revenue", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_DataSerializedAsJson()
+ {
+ // Arrange
+ var data = new List
+ {
+ new HorizontalBarChartSeries
+ {
+ ChartSeriesTitle = "Series 1",
+ ChartData = [ new HorizontalBarChartDataPoint { Legend = "Item A", Data = 50 } ],
+ },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("Item A", dataAttribute);
+ Assert.Contains("50", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_DataPoint_WithColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new HorizontalBarChartSeries
+ {
+ ChartData = [ new HorizontalBarChartDataPoint { Legend = "Colored", Data = 80, Color = DataVizPalette.Color3 } ],
+ },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("color3", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_DataPoint_WithCustomColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new HorizontalBarChartSeries
+ {
+ ChartData = [ new HorizontalBarChartDataPoint { Legend = "Custom", Data = 80, Color = DataVizPalette.Custom, CustomColor = "#E63946" } ],
+ },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-horizontal-bar-chart").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("#E63946", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_TooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend: @ctx.YValue
+
+
+ );
+
+ // Assert — portal div is present when TooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_EmptyData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-horizontal-bar-chart");
+ Assert.NotNull(element);
+ }
+
+ // HasRenderableData is protected, so a thin subclass exposes it for unit testing.
+ private sealed class TestableHorizontalBarChart : FluentHorizontalBarChart
+ {
+ public TestableHorizontalBarChart(LibraryConfiguration configuration) : base(configuration) { }
+ public bool HasRenderableDataPublic => HasRenderableData;
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HasRenderableData_True_WhenDataContainsSeries()
+ {
+ // Arrange
+#pragma warning disable BL0005
+ var chart = new TestableHorizontalBarChart(new LibraryConfiguration())
+ {
+ ChartData = DefaultData,
+ };
+#pragma warning restore BL0005
+
+ // Assert
+ Assert.True(chart.HasRenderableDataPublic);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChart_HasRenderableData_False_WhenDataIsEmpty()
+ {
+ // Arrange
+#pragma warning disable BL0005
+ var chart = new TestableHorizontalBarChart(new LibraryConfiguration())
+ {
+ ChartData = [],
+ };
+#pragma warning restore BL0005
+
+ // Assert
+ Assert.False(chart.HasRenderableDataPublic);
+ }
+}
diff --git a/tests/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxisTests.FluentHorizontalBarChartWithAxis_Default.verified.razor.html b/tests/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxisTests.FluentHorizontalBarChartWithAxis_Default.verified.razor.html
new file mode 100644
index 0000000000..0ab002ac37
--- /dev/null
+++ b/tests/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxisTests.FluentHorizontalBarChartWithAxis_Default.verified.razor.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxisTests.razor b/tests/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxisTests.razor
new file mode 100644
index 0000000000..8a59ccb019
--- /dev/null
+++ b/tests/Charts/Charts/HorizontalBarChartWithAxis/FluentHorizontalBarChartWithAxisTests.razor
@@ -0,0 +1,461 @@
+@using Microsoft.FluentUI.AspNetCore.Components.Charts
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ private static readonly IReadOnlyList DefaultData =
+ [
+ new HorizontalBarChartWithAxisDataPoint { X = 120, Y = "Category A", Legend = "Series 1" },
+ new HorizontalBarChartWithAxisDataPoint { X = 85, Y = "Category B", Legend = "Series 1" },
+ new HorizontalBarChartWithAxisDataPoint { X = 60, Y = "Category C", Legend = "Series 1" },
+ ];
+
+ public FluentHorizontalBarChartWithAxisTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_Default()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ cut.Verify();
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_ChartTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("chart-title");
+ Assert.Equal("Sales by Category", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_HideTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("hide-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_HideTooltip_False()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("hide-tooltip");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_HideLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("hide-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_HideLegends()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("hide-legends");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_RoundedCorners()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("round-corners");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_EnableGradient()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("enable-gradient");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_UseSingleColor()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("use-single-color");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_ShowYAxisLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("show-y-axis-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_ShowYAxisLabelsTooltip()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("show-y-axis-labels-tooltip");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_AllowMultipleLegendSelection()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("allow-multiple-legend-selection");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_LegendListLabel()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("legend-list-label");
+ Assert.Equal("Categories", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_BarHeight()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("bar-height");
+ Assert.Equal("24", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_XAxisTickCount()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("x-axis-tick-count");
+ Assert.Equal("4", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_YAxisPadding()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("y-axis-padding");
+ Assert.Equal("1", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_XAxisTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("x-axis-title");
+ Assert.Equal("Value", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_YAxisTitle()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("y-axis-title");
+ Assert.Equal("Category", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_XMinValue()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("x-min-value");
+ Assert.Equal("0", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_XMaxValue()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("x-max-value");
+ Assert.Equal("200", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_SupportNegativeData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("support-negative-data");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_RoundedTicks()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("rounded-ticks");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_WrapXAxisLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("wrap-x-axis-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_RotateXAxisLabels()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("rotate-x-axis-labels");
+ Assert.Equal("true", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_StrokeWidth()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("stroke-width");
+ Assert.Equal("2", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_Culture()
+ {
+ // Arrange
+ var culture = new System.Globalization.CultureInfo("fr-FR");
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("culture");
+ Assert.Equal("fr-FR", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_Culture_Null()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("culture");
+ Assert.Null(attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_Width()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("width");
+ Assert.Equal("700px", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_Height()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("height");
+ Assert.Equal("400px", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_Class()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-horizontal-bar-chart-with-axis");
+ Assert.Contains("my-axis-chart", element.GetAttribute("class") ?? string.Empty);
+ Assert.Contains("fluent-horizontal-bar-chart-with-axis", element.GetAttribute("class") ?? string.Empty);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_AdditionalAttributes()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var attribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("aria-label");
+ Assert.Equal("Sales by category", attribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_DataSerializedAsJson()
+ {
+ // Arrange
+ var data = new List
+ {
+ new HorizontalBarChartWithAxisDataPoint { X = 42, Y = "Test Category", Legend = "Series A" },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("Test Category", dataAttribute);
+ Assert.Contains("42", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_DataPoint_WithColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new HorizontalBarChartWithAxisDataPoint { X = 50, Y = "Cat", Legend = "L", Color = DataVizPalette.Color4 },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("color4", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_DataPoint_WithCustomColor()
+ {
+ // Arrange
+ var data = new List
+ {
+ new HorizontalBarChartWithAxisDataPoint { X = 50, Y = "Cat", Legend = "L", Color = DataVizPalette.Custom, CustomColor = "#457B9D" },
+ };
+
+ // Act
+ var cut = Render(@);
+
+ // Assert
+ var dataAttribute = cut.Find("fluent-horizontal-bar-chart-with-axis").GetAttribute("data");
+ Assert.NotNull(dataAttribute);
+ Assert.Contains("#457B9D", dataAttribute);
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_TooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend: @ctx.YValue
+
+
+ );
+
+ // Assert — portal div is present when TooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_CartesianTooltipTemplate()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ @ctx.Legend – @ctx.XValue
+
+
+ );
+
+ // Assert — portal div is present when CartesianTooltipTemplate is set
+ Assert.NotNull(cut.Find("[style*='display:none']"));
+ }
+
+ [Fact]
+ public void FluentHorizontalBarChartWithAxis_EmptyData()
+ {
+ // Arrange && Act
+ var cut = Render(@);
+
+ // Assert
+ var element = cut.Find("fluent-horizontal-bar-chart-with-axis");
+ Assert.NotNull(element);
+ }
+}
diff --git a/tests/Charts/Components.Charts.Tests.csproj b/tests/Charts/Components.Charts.Tests.csproj
new file mode 100644
index 0000000000..6616ee009b
--- /dev/null
+++ b/tests/Charts/Components.Charts.Tests.csproj
@@ -0,0 +1,78 @@
+
+
+
+ $(ExampleNetVersion)
+ Exe
+ enable
+ enable
+ latest
+ true
+ false
+ Microsoft.FluentUI.AspNetCore.Components.Charts.Tests
+ Microsoft.FluentUI.AspNetCore.Components.Charts.Tests
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $([System.String]::Copy('%(FileName)').Split('.')[0])
+ %(ParentFile).cs
+
+
+
+
+ $([System.String]::Copy('%(FileName)').Split('.')[0])
+ %(ParentFile).cs
+
+
+
+
+
+ $([System.String]::Copy('%(FileName)').Split('.')[0])
+ %(ParentFile).razor
+
+
+
+
+ $([System.String]::Copy('%(FileName)').Split('.')[0])
+ %(ParentFile).razor
+
+
+
+
+
+ ResXFileCodeGenerator
+ FluentLocalizer.Designer.cs
+
+
+ True
+ True
+ FluentLocalizer.resx
+
+
+
diff --git a/tests/Charts/Extensions/DataVizPaletteExtensionsTests.cs b/tests/Charts/Extensions/DataVizPaletteExtensionsTests.cs
new file mode 100644
index 0000000000..d282c8e5d9
--- /dev/null
+++ b/tests/Charts/Extensions/DataVizPaletteExtensionsTests.cs
@@ -0,0 +1,274 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using Microsoft.FluentUI.AspNetCore.Components.Charts;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Charts.Extensions;
+
+public class DataVizPaletteExtensionsTests
+{
+ // ── TryGetDataVizPaletteFromToken ─────────────────────────────────────────
+
+ [Fact]
+ public void TryGetDataVizPaletteFromToken_Null_ReturnsNull()
+ {
+ Assert.Null(DataVizPaletteExtensions.TryGetDataVizPaletteFromToken(null));
+ }
+
+ [Fact]
+ public void TryGetDataVizPaletteFromToken_EmptyString_ReturnsNull()
+ {
+ Assert.Null(DataVizPaletteExtensions.TryGetDataVizPaletteFromToken(string.Empty));
+ }
+
+ [Fact]
+ public void TryGetDataVizPaletteFromToken_CustomHexString_ReturnsNull()
+ {
+ Assert.Null(DataVizPaletteExtensions.TryGetDataVizPaletteFromToken("#FF5733"));
+ }
+
+ [Fact]
+ public void TryGetDataVizPaletteFromToken_CssVariable_ReturnsNull()
+ {
+ Assert.Null(DataVizPaletteExtensions.TryGetDataVizPaletteFromToken("var(--brand)"));
+ }
+
+ [Fact]
+ public void TryGetDataVizPaletteFromToken_CustomToken_ReturnsNull()
+ {
+ // "custom" is deliberately excluded from the TokenMap
+ Assert.Null(DataVizPaletteExtensions.TryGetDataVizPaletteFromToken("custom"));
+ }
+
+ [Theory]
+ [InlineData("color1", DataVizPalette.Color1)]
+ [InlineData("color5", DataVizPalette.Color5)]
+ [InlineData("color10", DataVizPalette.Color10)]
+ [InlineData("color20", DataVizPalette.Color20)]
+ [InlineData("color40", DataVizPalette.Color40)]
+ [InlineData("info", DataVizPalette.Info)]
+ [InlineData("disabled", DataVizPalette.Disabled)]
+ [InlineData("error", DataVizPalette.Error)]
+ [InlineData("warning", DataVizPalette.Warning)]
+ [InlineData("success", DataVizPalette.Success)]
+ public void TryGetDataVizPaletteFromToken_RecognizedToken_ReturnsEnumValue(string token, DataVizPalette expected)
+ {
+ Assert.Equal(expected, DataVizPaletteExtensions.TryGetDataVizPaletteFromToken(token));
+ }
+
+ [Theory]
+ [InlineData("COLOR1")]
+ [InlineData("Color1")]
+ [InlineData("INFO")]
+ [InlineData("Error")]
+ public void TryGetDataVizPaletteFromToken_IsCaseInsensitive(string token)
+ {
+ Assert.NotNull(DataVizPaletteExtensions.TryGetDataVizPaletteFromToken(token));
+ }
+
+ // ── ToDataVizPaletteHex (DataVizPalette, bool) ────────────────────────────
+
+ [Fact]
+ public void ToDataVizPaletteHex_Enum_Custom_ReturnsEmptyString()
+ {
+ // DataVizPalette.Custom is not in the Palette dictionary
+ Assert.Equal(string.Empty, DataVizPalette.Custom.ToDataVizPaletteHex());
+ }
+
+ [Theory]
+ [InlineData(DataVizPalette.Color1, "#637cef")]
+ [InlineData(DataVizPalette.Color2, "#e3008c")]
+ [InlineData(DataVizPalette.Color3, "#2aa0a4")]
+ [InlineData(DataVizPalette.Color5, "#13a10e")]
+ [InlineData(DataVizPalette.Color10, "#ae8c00")]
+ [InlineData(DataVizPalette.Info, "#015cda")]
+ [InlineData(DataVizPalette.Error, "#c50f1f")]
+ [InlineData(DataVizPalette.Warning, "#f7630c")]
+ [InlineData(DataVizPalette.Success, "#107c10")]
+ public void ToDataVizPaletteHex_Enum_LightTheme_ReturnsExpectedHex(DataVizPalette palette, string expectedHex)
+ {
+ Assert.Equal(expectedHex, palette.ToDataVizPaletteHex(isDarkTheme: false));
+ }
+
+ [Theory]
+ [InlineData(DataVizPalette.Color11, false, "#3c51b4")]
+ [InlineData(DataVizPalette.Color11, true, "#93a4f4")]
+ [InlineData(DataVizPalette.Color12, false, "#ad006a")]
+ [InlineData(DataVizPalette.Color12, true, "#ee5fb7")]
+ [InlineData(DataVizPalette.Disabled, false, "#dbdbdb")]
+ [InlineData(DataVizPalette.Disabled, true, "#4d4d4d")]
+ [InlineData(DataVizPalette.Error, false, "#c50f1f")]
+ [InlineData(DataVizPalette.Error, true, "#dc626d")]
+ [InlineData(DataVizPalette.Success, false, "#107c10")]
+ [InlineData(DataVizPalette.Success, true, "#54b054")]
+ public void ToDataVizPaletteHex_Enum_DarkThemeVariant_ReturnsCorrectColor(
+ DataVizPalette palette, bool isDark, string expectedHex)
+ {
+ Assert.Equal(expectedHex, palette.ToDataVizPaletteHex(isDarkTheme: isDark));
+ }
+
+ [Theory]
+ [InlineData(DataVizPalette.Color1)]
+ [InlineData(DataVizPalette.Color5)]
+ [InlineData(DataVizPalette.Info)]
+ public void ToDataVizPaletteHex_Enum_SingleEntryPalette_DarkTheme_ReturnsSameLightHex(DataVizPalette palette)
+ {
+ // Colors with only one entry in the palette fall back to index 0 for dark theme too
+ var light = palette.ToDataVizPaletteHex(isDarkTheme: false);
+ var dark = palette.ToDataVizPaletteHex(isDarkTheme: true);
+
+ Assert.Equal(light, dark);
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_Enum_ReturnsHexStartingWithHash()
+ {
+ var hex = DataVizPalette.Color3.ToDataVizPaletteHex();
+
+ Assert.StartsWith("#", hex);
+ }
+
+ // ── ToDataVizPaletteHex (DataVizPalette?, bool) — nullable, no fallback ───
+
+ [Fact]
+ public void ToDataVizPaletteHex_NullablePalette_Null_ReturnsEmptyString()
+ {
+ DataVizPalette? palette = null;
+
+ Assert.Equal(string.Empty, palette.ToDataVizPaletteHex());
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_NullablePalette_WithValue_ReturnsPaletteHex()
+ {
+ DataVizPalette? palette = DataVizPalette.Color4;
+
+ Assert.Equal("#9373c0", palette.ToDataVizPaletteHex());
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_NullablePalette_DarkTheme_DelegatesToNonNullable()
+ {
+ DataVizPalette? palette = DataVizPalette.Color11;
+
+ Assert.Equal(DataVizPalette.Color11.ToDataVizPaletteHex(isDarkTheme: true),
+ palette.ToDataVizPaletteHex(isDarkTheme: true));
+ }
+
+ // ── ToDataVizPaletteHex (DataVizPalette?, string?, bool) — with fallback ──
+
+ [Fact]
+ public void ToDataVizPaletteHex_WithFallback_Null_ReturnsEmptyString()
+ {
+ DataVizPalette? palette = null;
+
+ Assert.Equal(string.Empty, palette.ToDataVizPaletteHex(fallback: null));
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_WithFallback_Null_ReturnsCustomColorFallback()
+ {
+ DataVizPalette? palette = null;
+
+ Assert.Equal("#A23B72", palette.ToDataVizPaletteHex(fallback: "#A23B72"));
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_WithFallback_NullPaletteAndCssVariable_ReturnsCssVariable()
+ {
+ DataVizPalette? palette = null;
+
+ Assert.Equal("var(--brand)", palette.ToDataVizPaletteHex(fallback: "var(--brand)"));
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_WithFallback_PaletteSet_IgnoresFallback()
+ {
+ DataVizPalette? palette = DataVizPalette.Color6;
+
+ var result = palette.ToDataVizPaletteHex(fallback: "#FF0000");
+
+ Assert.Equal("#3a96dd", result);
+ Assert.DoesNotContain("#FF0000", result);
+ }
+
+ [Theory]
+ [InlineData(DataVizPalette.Error, "#c50f1f", false)]
+ [InlineData(DataVizPalette.Error, "#dc626d", true)]
+ [InlineData(DataVizPalette.Success, "#107c10", false)]
+ [InlineData(DataVizPalette.Success, "#54b054", true)]
+ public void ToDataVizPaletteHex_WithFallback_PaletteSet_RespectsIsDarkTheme(
+ DataVizPalette value, string expectedHex, bool isDark)
+ {
+ DataVizPalette? palette = value;
+
+ Assert.Equal(expectedHex, palette.ToDataVizPaletteHex(fallback: "#ignored", isDarkTheme: isDark));
+ }
+
+ // ── ToDataVizPaletteHex (this string?, bool) ──────────────────────────────
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_Null_ReturnsEmptyString()
+ {
+ string? token = null;
+
+ Assert.Equal(string.Empty, token.ToDataVizPaletteHex());
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_EmptyString_ReturnsEmptyString()
+ {
+ Assert.Equal(string.Empty, string.Empty.ToDataVizPaletteHex());
+ }
+
+ [Theory]
+ [InlineData("color1", "#637cef")]
+ [InlineData("color2", "#e3008c")]
+ [InlineData("color5", "#13a10e")]
+ [InlineData("color10", "#ae8c00")]
+ [InlineData("info", "#015cda")]
+ [InlineData("error", "#c50f1f")]
+ [InlineData("warning", "#f7630c")]
+ [InlineData("success", "#107c10")]
+ public void ToDataVizPaletteHex_String_RecognizedToken_ReturnsHex(string token, string expectedHex)
+ {
+ Assert.Equal(expectedHex, token.ToDataVizPaletteHex());
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_CustomHex_ReturnedUnchanged()
+ {
+ Assert.Equal("#FF5733", "#FF5733".ToDataVizPaletteHex());
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_CssVariable_ReturnedUnchanged()
+ {
+ Assert.Equal("var(--custom)", "var(--custom)".ToDataVizPaletteHex());
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_DarkTheme_ForTokenWithDarkVariant()
+ {
+ // "error" has a dark variant (#dc626d)
+ Assert.Equal("#dc626d", "error".ToDataVizPaletteHex(isDarkTheme: true));
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_DarkTheme_ForTokenWithoutDarkVariant_ReturnsSameAsLight()
+ {
+ // "color1" has only one palette entry
+ var light = "color1".ToDataVizPaletteHex(isDarkTheme: false);
+ var dark = "color1".ToDataVizPaletteHex(isDarkTheme: true);
+
+ Assert.Equal(light, dark);
+ }
+
+ [Fact]
+ public void ToDataVizPaletteHex_String_UnknownToken_ReturnedUnchanged()
+ {
+ Assert.Equal("not-a-token", "not-a-token".ToDataVizPaletteHex());
+ }
+}
diff --git a/tests/Charts/Models/CalloutAccessibilityDataTests.cs b/tests/Charts/Models/CalloutAccessibilityDataTests.cs
new file mode 100644
index 0000000000..79f651c240
--- /dev/null
+++ b/tests/Charts/Models/CalloutAccessibilityDataTests.cs
@@ -0,0 +1,74 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using System.Text.Json;
+using Microsoft.FluentUI.AspNetCore.Components.Charts;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Charts.Models;
+
+public class CalloutAccessibilityDataTests
+{
+ [Fact]
+ public void AriaLabel_Default_IsNull()
+ {
+ var data = new CalloutAccessibilityData();
+
+ Assert.Null(data.AriaLabel);
+ }
+
+ [Fact]
+ public void AriaLabel_Init_AssignsValue()
+ {
+ var data = new CalloutAccessibilityData
+ {
+ AriaLabel = "Sales for Q1"
+ };
+
+ Assert.Equal("Sales for Q1", data.AriaLabel);
+ }
+
+ [Fact]
+ public void Serialize_UsesAriaLabelJsonPropertyName()
+ {
+ var data = new CalloutAccessibilityData
+ {
+ AriaLabel = "Task callout"
+ };
+
+ var json = JsonSerializer.Serialize(data);
+
+ Assert.Contains("\"ariaLabel\":\"Task callout\"", json);
+ Assert.DoesNotContain("\"AriaLabel\"", json);
+ }
+
+ [Fact]
+ public void Deserialize_ReadsAriaLabelJsonPropertyName()
+ {
+ const string json = "{\"ariaLabel\":\"Revenue callout\"}";
+
+ var data = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(data);
+ Assert.Equal("Revenue callout", data.AriaLabel);
+ }
+
+ [Fact]
+ public void RecordEquality_SameAriaLabel_AreEqual()
+ {
+ var left = new CalloutAccessibilityData { AriaLabel = "Label" };
+ var right = new CalloutAccessibilityData { AriaLabel = "Label" };
+
+ Assert.Equal(left, right);
+ }
+
+ [Fact]
+ public void RecordEquality_DifferentAriaLabel_AreNotEqual()
+ {
+ var left = new CalloutAccessibilityData { AriaLabel = "Label A" };
+ var right = new CalloutAccessibilityData { AriaLabel = "Label B" };
+
+ Assert.NotEqual(left, right);
+ }
+}
diff --git a/tests/Charts/Models/ChartAxisValueJsonConverterTests.cs b/tests/Charts/Models/ChartAxisValueJsonConverterTests.cs
new file mode 100644
index 0000000000..3d7e9b6a91
--- /dev/null
+++ b/tests/Charts/Models/ChartAxisValueJsonConverterTests.cs
@@ -0,0 +1,123 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using System.Globalization;
+using System.Text.Json;
+using Microsoft.FluentUI.AspNetCore.Components.Charts;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Charts.Models;
+
+public class ChartAxisValueJsonConverterTests
+{
+ [Fact]
+ public void Deserialize_Number_ReadsAsNumericChartAxisValue()
+ {
+ // Arrange
+ const string json = "123.5";
+
+ // Act
+ var result = JsonSerializer.Deserialize(json);
+
+ // Assert
+ Assert.Equal((ChartAxisValue)123.5, result);
+ }
+
+ [Fact]
+ public void Deserialize_IsoDate_ReadsAsDateChartAxisValue()
+ {
+ // Arrange
+ const string json = "\"2024-01-31T12:34:56.0000000+00:00\"";
+
+ // Act
+ var result = JsonSerializer.Deserialize(json);
+
+ // Assert
+ var expectedDate = DateTimeOffset.Parse("2024-01-31T12:34:56.0000000+00:00", CultureInfo.InvariantCulture);
+ Assert.Equal((ChartAxisValue)expectedDate, result);
+ }
+
+ [Fact]
+ public void Deserialize_InvalidDateString_ThrowsFormatException()
+ {
+ // Arrange
+ const string json = "\"not-a-date\"";
+
+ // Act + Assert
+ Assert.Throws(() => JsonSerializer.Deserialize(json));
+ }
+
+ [Fact]
+ public void Serialize_NumericChartAxisValue_WritesJsonNumber()
+ {
+ // Arrange
+ ChartAxisValue value = 42.75;
+
+ // Act
+ var json = JsonSerializer.Serialize(value);
+
+ // Assert
+ Assert.Equal("42.75", json);
+ }
+
+ [Fact]
+ public void Serialize_DateChartAxisValue_WritesIso8601String()
+ {
+ // Arrange
+ var date = new DateTimeOffset(2024, 2, 29, 7, 8, 9, TimeSpan.FromHours(2));
+ ChartAxisValue value = date;
+
+ // Act
+ var json = JsonSerializer.Serialize(value);
+
+ // Assert
+ using var doc = JsonDocument.Parse(json);
+ var parsed = doc.RootElement.GetString();
+ Assert.Equal(date.ToString("O", CultureInfo.InvariantCulture), parsed);
+ }
+
+ [Fact]
+ public void Serialize_DateTimeUnspecified_UsesUtcKindInOutput()
+ {
+ // Arrange
+ var dateTime = new DateTime(2024, 3, 1, 0, 0, 0, DateTimeKind.Unspecified);
+ ChartAxisValue value = dateTime;
+
+ // Act
+ var json = JsonSerializer.Serialize(value);
+
+ // Assert
+ using var doc = JsonDocument.Parse(json);
+ var parsed = doc.RootElement.GetString();
+ Assert.EndsWith("+00:00", parsed);
+ }
+
+ [Fact]
+ public void RoundTrip_Number_PreservesValue()
+ {
+ // Arrange
+ ChartAxisValue original = -999.125;
+
+ // Act
+ var json = JsonSerializer.Serialize(original);
+ var result = JsonSerializer.Deserialize(json);
+
+ // Assert
+ Assert.Equal(original, result);
+ }
+
+ [Fact]
+ public void RoundTrip_Date_PreservesValue()
+ {
+ // Arrange
+ ChartAxisValue original = new DateTimeOffset(2025, 5, 6, 11, 12, 13, TimeSpan.Zero);
+
+ // Act
+ var json = JsonSerializer.Serialize(original);
+ var result = JsonSerializer.Deserialize(json);
+
+ // Assert
+ Assert.Equal(original, result);
+ }
+}
diff --git a/tests/Charts/Models/ChartAxisValueTests.cs b/tests/Charts/Models/ChartAxisValueTests.cs
new file mode 100644
index 0000000000..f183cf1471
--- /dev/null
+++ b/tests/Charts/Models/ChartAxisValueTests.cs
@@ -0,0 +1,149 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using Microsoft.FluentUI.AspNetCore.Components.Charts;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Charts.Models;
+
+public class ChartAxisValueTests
+{
+ [Fact]
+ public void DefaultValue_IsNumericWithZeroNumber()
+ {
+ // Arrange
+ var value = default(ChartAxisValue);
+
+ // Assert
+ Assert.False(value.IsDate);
+ Assert.Equal(0.0, value.NumberValue);
+ Assert.Equal(default, value.DateValue);
+ }
+
+ [Fact]
+ public void ImplicitFromDouble_SetsNumericValue()
+ {
+ ChartAxisValue value = 42.5;
+
+ Assert.False(value.IsDate);
+ Assert.Equal(42.5, value.NumberValue);
+ }
+
+ [Fact]
+ public void ImplicitFromDateTimeOffset_SetsDateValue()
+ {
+ var dto = new DateTimeOffset(2024, 6, 1, 10, 20, 30, TimeSpan.FromHours(2));
+ ChartAxisValue value = dto;
+
+ Assert.True(value.IsDate);
+ Assert.Equal(dto, value.DateValue);
+ }
+
+ [Fact]
+ public void ImplicitFromDateTime_Unspecified_TreatedAsUtc()
+ {
+ var dt = new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Unspecified);
+
+ ChartAxisValue value = dt;
+
+ Assert.True(value.IsDate);
+ Assert.Equal(TimeSpan.Zero, value.DateValue.Offset);
+ Assert.Equal(2024, value.DateValue.Year);
+ Assert.Equal(1, value.DateValue.Month);
+ Assert.Equal(2, value.DateValue.Day);
+ }
+
+ [Fact]
+ public void ImplicitFromDateTime_Utc_PreservedAsUtc()
+ {
+ var dt = new DateTime(2024, 3, 4, 5, 6, 7, DateTimeKind.Utc);
+
+ ChartAxisValue value = dt;
+
+ Assert.True(value.IsDate);
+ Assert.Equal(TimeSpan.Zero, value.DateValue.Offset);
+ Assert.Equal(dt, value.DateValue.UtcDateTime);
+ }
+
+ [Fact]
+ public void Equals_WithSameNumericValue_ReturnsTrue()
+ {
+ ChartAxisValue left = 12.25;
+ ChartAxisValue right = 12.25;
+
+ Assert.True(left.Equals(right));
+ Assert.True(left == right);
+ Assert.False(left != right);
+ }
+
+ [Fact]
+ public void Equals_WithDifferentNumericValue_ReturnsFalse()
+ {
+ ChartAxisValue left = 12.25;
+ ChartAxisValue right = 13.25;
+
+ Assert.False(left.Equals(right));
+ Assert.False(left == right);
+ Assert.True(left != right);
+ }
+
+ [Fact]
+ public void Equals_WithSameDateValue_ReturnsTrue()
+ {
+ var dto = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ ChartAxisValue left = dto;
+ ChartAxisValue right = dto;
+
+ Assert.True(left.Equals(right));
+ Assert.True(left == right);
+ Assert.False(left != right);
+ }
+
+ [Fact]
+ public void Equals_NumberAndDate_AreNotEqual()
+ {
+ ChartAxisValue number = 10;
+ ChartAxisValue date = new DateTimeOffset(1970, 1, 1, 0, 0, 10, TimeSpan.Zero);
+
+ Assert.False(number.Equals(date));
+ Assert.False(number == date);
+ Assert.True(number != date);
+ }
+
+ [Fact]
+ public void Equals_Object_WithSameValueType_ReturnsTrue()
+ {
+ ChartAxisValue value = 5.5;
+ object boxed = (ChartAxisValue)5.5;
+
+ Assert.True(value.Equals(boxed));
+ }
+
+ [Fact]
+ public void Equals_Object_WithNullOrDifferentType_ReturnsFalse()
+ {
+ ChartAxisValue value = 5.5;
+
+ Assert.False(value.Equals(null));
+ Assert.False(value.Equals("5.5"));
+ }
+
+ [Fact]
+ public void GetHashCode_SameValues_AreEqual()
+ {
+ ChartAxisValue left = 42;
+ ChartAxisValue right = 42;
+
+ Assert.Equal(left.GetHashCode(), right.GetHashCode());
+ }
+
+ [Fact]
+ public void GetHashCode_DifferentKinds_AreDifferent()
+ {
+ ChartAxisValue number = 42;
+ ChartAxisValue date = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
+
+ Assert.NotEqual(number.GetHashCode(), date.GetHashCode());
+ }
+}
diff --git a/tests/Charts/Models/FunnelSubValueTests.cs b/tests/Charts/Models/FunnelSubValueTests.cs
new file mode 100644
index 0000000000..e6a53782c7
--- /dev/null
+++ b/tests/Charts/Models/FunnelSubValueTests.cs
@@ -0,0 +1,210 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using System.Text.Json;
+using Microsoft.FluentUI.AspNetCore.Components.Charts;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Charts.Models;
+
+public class FunnelSubValueTests
+{
+ // ── Defaults ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void FunnelSubValue_Category_EmptyString_ByDefault()
+ {
+ var sub = new FunnelSubValue();
+
+ Assert.Equal(string.Empty, sub.Category);
+ }
+
+ [Fact]
+ public void FunnelSubValue_Value_Zero_ByDefault()
+ {
+ var sub = new FunnelSubValue();
+
+ Assert.Equal(0, sub.Value);
+ }
+
+ [Fact]
+ public void FunnelSubValue_Color_Null_ByDefault()
+ {
+ var sub = new FunnelSubValue();
+
+ Assert.Null(sub.Color);
+ }
+
+ [Fact]
+ public void FunnelSubValue_CustomColor_Null_ByDefault()
+ {
+ var sub = new FunnelSubValue();
+
+ Assert.Null(sub.CustomColor);
+ }
+
+ [Fact]
+ public void FunnelSubValue_SerializedColor_Null_WhenNeitherColorNorCustomColorSet()
+ {
+ var sub = new FunnelSubValue();
+
+ Assert.Null(sub.SerializedColor);
+ }
+
+ // ── Category and Value ───────────────────────────────────────────────────
+
+ [Fact]
+ public void FunnelSubValue_Category_RetainsValue()
+ {
+ var sub = new FunnelSubValue { Category = "Segment A" };
+
+ Assert.Equal("Segment A", sub.Category);
+ }
+
+ [Fact]
+ public void FunnelSubValue_Value_RetainsNumericValue()
+ {
+ var sub = new FunnelSubValue { Value = 123.45 };
+
+ Assert.Equal(123.45, sub.Value);
+ }
+
+ // ── Color / SerializedColor ───────────────────────────────────────────────
+
+ [Fact]
+ public void FunnelSubValue_SerializedColor_ReturnsPaletteToken_WhenColorIsSet()
+ {
+ var sub = new FunnelSubValue { Color = DataVizPalette.Color3 };
+
+ Assert.Equal("color3", sub.SerializedColor);
+ }
+
+ [Theory]
+ [InlineData(DataVizPalette.Color1, "color1")]
+ [InlineData(DataVizPalette.Color2, "color2")]
+ [InlineData(DataVizPalette.Color10, "color10")]
+ [InlineData(DataVizPalette.Info, "info")]
+ [InlineData(DataVizPalette.Success, "success")]
+ [InlineData(DataVizPalette.Warning, "warning")]
+ [InlineData(DataVizPalette.Error, "error")]
+ public void FunnelSubValue_SerializedColor_ReturnsPaletteToken_ForAllPaletteValues(DataVizPalette value, string expectedToken)
+ {
+ var sub = new FunnelSubValue { Color = value };
+
+ Assert.Equal(expectedToken, sub.SerializedColor);
+ }
+
+ // ── CustomColor / SerializedColor ─────────────────────────────────────────
+
+ [Fact]
+ public void FunnelSubValue_SerializedColor_ReturnsCustomColor_WhenColorIsCustom()
+ {
+ var sub = new FunnelSubValue { Color = DataVizPalette.Custom, CustomColor = "#FF5733" };
+
+ Assert.Equal("#FF5733", sub.SerializedColor);
+ }
+
+ [Fact]
+ public void FunnelSubValue_SerializedColor_ReturnsCssVariable_WhenColorIsCustom()
+ {
+ var sub = new FunnelSubValue { Color = DataVizPalette.Custom, CustomColor = "var(--brand-color)" };
+
+ Assert.Equal("var(--brand-color)", sub.SerializedColor);
+ }
+
+ [Fact]
+ public void FunnelSubValue_SerializedColor_Null_WhenColorIsCustom_AndCustomColorIsNull()
+ {
+ // Custom with no CustomColor string → SerializedColor falls through to null
+ var sub = new FunnelSubValue { Color = DataVizPalette.Custom, CustomColor = null };
+
+ Assert.Null(sub.SerializedColor);
+ }
+
+ // ── JSON serialization ────────────────────────────────────────────────────
+
+ [Fact]
+ public void FunnelSubValue_Json_ContainsCategoryAndValue()
+ {
+ var data = new FunnelDataPoint
+ {
+ Stage = "Top",
+ Value = 500,
+ SubValues = [new FunnelSubValue { Category = "Alpha", Value = 300 }],
+ };
+
+ var json = ChartJson.Serialize([data]);
+
+ Assert.Contains("\"category\":\"Alpha\"", json);
+ Assert.Contains("\"value\":300", json);
+ }
+
+ [Fact]
+ public void FunnelSubValue_Json_ContainsPaletteColorToken()
+ {
+ var data = new FunnelDataPoint
+ {
+ Stage = "Middle",
+ Value = 200,
+ SubValues = [new FunnelSubValue { Category = "Beta", Value = 100, Color = DataVizPalette.Color5 }],
+ };
+
+ var json = ChartJson.Serialize([data]);
+
+ Assert.Contains("\"color\":\"color5\"", json);
+ }
+
+ [Fact]
+ public void FunnelSubValue_Json_ContainsCustomColorHex()
+ {
+ var data = new FunnelDataPoint
+ {
+ Stage = "Bottom",
+ Value = 100,
+ SubValues = [new FunnelSubValue { Category = "Gamma", Value = 50, Color = DataVizPalette.Custom, CustomColor = "#0099BC" }],
+ };
+
+ var json = ChartJson.Serialize([data]);
+
+ Assert.Contains("\"color\":\"#0099BC\"", json);
+ }
+
+ [Fact]
+ public void FunnelSubValue_Json_OmitsColorProperty_WhenNeitherColorNorCustomColorSet()
+ {
+ var data = new FunnelDataPoint
+ {
+ Stage = "Top",
+ Value = 400,
+ SubValues = [new FunnelSubValue { Category = "Delta", Value = 200 }],
+ };
+
+ var json = ChartJson.Serialize([data]);
+
+ // JsonIgnore(WhenWritingNull) means the "color" key should not appear in sub value
+ using var doc = JsonDocument.Parse(json);
+ var subValue = doc.RootElement[0].GetProperty("subValues")[0];
+ Assert.False(subValue.TryGetProperty("color", out _));
+ }
+
+ // ── Record equality ───────────────────────────────────────────────────────
+
+ [Fact]
+ public void FunnelSubValue_RecordEquality_SameValues_AreEqual()
+ {
+ var a = new FunnelSubValue { Category = "X", Value = 10, Color = DataVizPalette.Color1 };
+ var b = new FunnelSubValue { Category = "X", Value = 10, Color = DataVizPalette.Color1 };
+
+ Assert.Equal(a, b);
+ }
+
+ [Fact]
+ public void FunnelSubValue_RecordEquality_DifferentValues_AreNotEqual()
+ {
+ var a = new FunnelSubValue { Category = "X", Value = 10 };
+ var b = new FunnelSubValue { Category = "X", Value = 20 };
+
+ Assert.NotEqual(a, b);
+ }
+}
diff --git a/tests/Charts/Models/TooltipContextTests.cs b/tests/Charts/Models/TooltipContextTests.cs
new file mode 100644
index 0000000000..d2d7686fac
--- /dev/null
+++ b/tests/Charts/Models/TooltipContextTests.cs
@@ -0,0 +1,160 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using Microsoft.FluentUI.AspNetCore.Components.Charts;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Charts.Models;
+
+public class TooltipContextTests
+{
+ // ── Color ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TooltipContext_Color_Null_ByDefault()
+ {
+ var ctx = new TooltipContext();
+
+ Assert.Null(ctx.Color);
+ }
+
+ [Fact]
+ public void TooltipContext_Color_RetainsPaletteValue()
+ {
+ var ctx = new TooltipContext { Color = DataVizPalette.Color5 };
+
+ Assert.Equal(DataVizPalette.Color5, ctx.Color);
+ }
+
+ [Theory]
+ [InlineData(DataVizPalette.Color1)]
+ [InlineData(DataVizPalette.Color2)]
+ [InlineData(DataVizPalette.Color10)]
+ [InlineData(DataVizPalette.Info)]
+ [InlineData(DataVizPalette.Success)]
+ [InlineData(DataVizPalette.Warning)]
+ [InlineData(DataVizPalette.Error)]
+ public void TooltipContext_Color_AcceptsAllPaletteValues(DataVizPalette value)
+ {
+ var ctx = new TooltipContext { Color = value };
+
+ Assert.Equal(value, ctx.Color);
+ }
+
+ [Fact]
+ public void TooltipContext_Color_NullWhenCustomColorIsUsed()
+ {
+ // When a data point uses DataVizPalette.Custom, the JS bridge sets Color=null
+ // and CustomColor to the raw hex string instead.
+ var ctx = new TooltipContext { Color = null, CustomColor = "#FF5733" };
+
+ Assert.Null(ctx.Color);
+ Assert.Equal("#FF5733", ctx.CustomColor);
+ }
+
+ // ── CustomColor ──────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TooltipContext_CustomColor_Null_ByDefault()
+ {
+ var ctx = new TooltipContext();
+
+ Assert.Null(ctx.CustomColor);
+ }
+
+ [Fact]
+ public void TooltipContext_CustomColor_RetainsHexString()
+ {
+ var ctx = new TooltipContext { CustomColor = "#C19A6B" };
+
+ Assert.Equal("#C19A6B", ctx.CustomColor);
+ }
+
+ [Fact]
+ public void TooltipContext_CustomColor_RetainsCssVariableString()
+ {
+ var ctx = new TooltipContext { CustomColor = "var(--custom-brand)" };
+
+ Assert.Equal("var(--custom-brand)", ctx.CustomColor);
+ }
+
+ [Fact]
+ public void TooltipContext_ToDataVizPaletteHex_ReturnsCustomColorWhenColorIsNull()
+ {
+ // The recommended pattern: Color?.ToDataVizPaletteHex(CustomColor) falls back to CustomColor
+ var ctx = new TooltipContext { Color = null, CustomColor = "#A23B72" };
+
+ var resolved = ctx.Color.ToDataVizPaletteHex(ctx.CustomColor);
+
+ Assert.Equal("#A23B72", resolved);
+ }
+
+ [Fact]
+ public void TooltipContext_ToDataVizPaletteHex_ReturnsPaletteHexWhenColorIsSet()
+ {
+ var ctx = new TooltipContext { Color = DataVizPalette.Color1, CustomColor = null };
+
+ var resolved = ctx.Color.ToDataVizPaletteHex(ctx.CustomColor);
+
+ Assert.NotEmpty(resolved);
+ Assert.StartsWith("#", resolved);
+ }
+
+ // ── RawJson ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TooltipContext_RawJson_Null_ByDefault()
+ {
+ var ctx = new TooltipContext();
+
+ Assert.Null(ctx.RawJson);
+ }
+
+ [Fact]
+ public void TooltipContext_RawJson_RetainsJsonString()
+ {
+ const string json = """{"legend":"Task A","x":{"start":1704067200000,"end":1705276800000}}""";
+
+ var ctx = new TooltipContext { RawJson = json };
+
+ Assert.Equal(json, ctx.RawJson);
+ }
+
+ [Fact]
+ public void TooltipContext_RawJson_CanBeDeserialized()
+ {
+ const string json = """{"legend":"Category A","data":42}""";
+ var ctx = new TooltipContext { RawJson = json };
+
+ // Verify the raw JSON is valid and accessible for custom parsing
+ using var doc = System.Text.Json.JsonDocument.Parse(ctx.RawJson!);
+ Assert.Equal("Category A", doc.RootElement.GetProperty("legend").GetString());
+ Assert.Equal(42, doc.RootElement.GetProperty("data").GetInt32());
+ }
+
+ // ── Combined: all typed members together ─────────────────────────────────
+
+ [Fact]
+ public void TooltipContext_AllMembers_InitializedTogether()
+ {
+ const string raw = """{"legend":"Item","data":99,"color":"color3"}""";
+
+ var ctx = new TooltipContext
+ {
+ Legend = "Item",
+ YValue = "99",
+ XValue = null,
+ Color = DataVizPalette.Color3,
+ CustomColor = null,
+ RawJson = raw,
+ };
+
+ Assert.Equal("Item", ctx.Legend);
+ Assert.Equal("99", ctx.YValue);
+ Assert.Null(ctx.XValue);
+ Assert.Equal(DataVizPalette.Color3, ctx.Color);
+ Assert.Null(ctx.CustomColor);
+ Assert.Equal(raw, ctx.RawJson);
+ }
+}
diff --git a/tests/Charts/_Imports.razor b/tests/Charts/_Imports.razor
new file mode 100644
index 0000000000..d9fd39883b
--- /dev/null
+++ b/tests/Charts/_Imports.razor
@@ -0,0 +1,10 @@
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.JSInterop
+@using Microsoft.Extensions.DependencyInjection
+@using AngleSharp.Dom
+@using Bunit
+
+@using Microsoft.FluentUI.AspNetCore.Components
+@using Microsoft.FluentUI.AspNetCore.Components.Tests.Verify;
+@using Microsoft.FluentUI.AspNetCore.Components.Charts;
diff --git a/tests/Charts/_UpdateVerifiedFiles.cmd b/tests/Charts/_UpdateVerifiedFiles.cmd
new file mode 100644
index 0000000000..f1c74a234e
--- /dev/null
+++ b/tests/Charts/_UpdateVerifiedFiles.cmd
@@ -0,0 +1,3 @@
+REM Update or create all .Verified files
+SET UPDATE_VERIFIED_FILES=TRUE
+dotnet test
\ No newline at end of file
diff --git a/tests/Charts/xunit.runner.json b/tests/Charts/xunit.runner.json
new file mode 100644
index 0000000000..cbe8cb6a6a
--- /dev/null
+++ b/tests/Charts/xunit.runner.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://xunit.net/schema/v3.xunit.runner.schema.json",
+ "parallelizeAssembly": false,
+ "parallelizeTestCollections": false
+}
diff --git a/tests/Core/Components.Tests.csproj b/tests/Core/Components.Tests.csproj
index 825f0b29da..c00bdbf9e4 100644
--- a/tests/Core/Components.Tests.csproj
+++ b/tests/Core/Components.Tests.csproj
@@ -1,4 +1,4 @@
-
+
$(ExampleNetVersion)
@@ -32,6 +32,7 @@
+
@@ -56,6 +57,14 @@
+
+
+
+
+
+
+
+
$([System.String]::Copy('%(FileName)').Split('.')[0])
%(ParentFile).razor
@@ -73,4 +82,5 @@
FluentLocalizer.resx
-
+
+
diff --git a/tests/Core/Components/DataGrid/FluentDataGridTests.razor b/tests/Core/Components/DataGrid/FluentDataGridTests.razor
index f67665c707..64c09d430f 100644
--- a/tests/Core/Components/DataGrid/FluentDataGridTests.razor
+++ b/tests/Core/Components/DataGrid/FluentDataGridTests.razor
@@ -1443,6 +1443,46 @@
cut.WaitForAssertion(() => Assert.NotNull(cut.FindComponent>()));
}
+ [Theory]
+ [InlineData(0, new[] { "created", "name", "status" })]
+ [InlineData(1, new[] { "created", "name", "status" })]
+ [InlineData(2, new[] { "name", "status", "created" })]
+ [InlineData(3, new[] { "name", "status", "created" })]
+ public async Task FluentDataGrid_ColumnReorderOptions_Buttons_InvokeMoveHandlers(int buttonIndex, string[] expectedOrder)
+ {
+ // Arrange
+ FluentDataGrid? grid = default!;
+
+ var cut = Render>(
+ @
+
+
+
+ );
+
+ var activeColumn = grid!._columns.Single(x => x.ColumnId == "created");
+ await cut.InvokeAsync(() => grid.ShowColumnReorderAsync(activeColumn));
+
+ var reorderOptions = cut.FindComponent>();
+ var moveButtons = reorderOptions.FindAll("fluent-button");
+
+ // Act
+ moveButtons[buttonIndex].Click();
+
+ // Assert
+ cut.WaitForAssertion(() =>
+ {
+ Assert.Equal(expectedOrder, grid.GetColumnOrder());
+ Assert.Equal(expectedOrder, cut.FindAll("th .col-title-text")
+ .Select(x => x.TextContent.Trim().ToLowerInvariant())
+ .ToArray());
+ });
+ }
+
[Fact]
public async Task FluentDataGrid_SetColumnWidthExact()
{
diff --git a/tests/Core/Components/Dialog/DialogServiceInternalTests.cs b/tests/Core/Components/Dialog/DialogServiceInternalTests.cs
new file mode 100644
index 0000000000..3e670ecbf9
--- /dev/null
+++ b/tests/Core/Components/Dialog/DialogServiceInternalTests.cs
@@ -0,0 +1,37 @@
+// ------------------------------------------------------------------------
+// This file is licensed to you under the MIT License.
+// ------------------------------------------------------------------------
+
+using Bunit;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace Microsoft.FluentUI.AspNetCore.Components.Tests.Components.Dialog;
+
+public class DialogServiceInternalTests : Bunit.BunitContext
+{
+ [Fact]
+ public async Task RemoveDialogFromProviderAsync_WhenDialogIdDoesNotExist_ThrowsInvalidOperationException()
+ {
+ // Arrange
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents(options => options.UseGlobalOverlay = false);
+
+ var dialogService = (DialogService)Services.GetRequiredService();
+
+ // Render provider so provider Id is available and ProviderNotAvailable guard does not interfere.
+ _ = Render();
+
+ // Create an instance that is intentionally NOT added to ServiceProvider.Items.
+ var missing = new DialogInstance(dialogService, typeof(FluentDialogProvider), new DialogOptions { Id = "missing-id" });
+
+ // Act + Assert
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await dialogService.RemoveDialogFromProviderAsync(missing);
+ });
+
+ Assert.Contains("missing-id", ex.Message, StringComparison.Ordinal);
+ Assert.Contains("doesn't exist", ex.Message, StringComparison.Ordinal);
+ }
+}
diff --git a/tests/Core/Components/Dialog/Overlay/OverlayServiceDisabledTests.razor b/tests/Core/Components/Dialog/Overlay/OverlayServiceDisabledTests.razor
new file mode 100644
index 0000000000..7dcc67d1ee
--- /dev/null
+++ b/tests/Core/Components/Dialog/Overlay/OverlayServiceDisabledTests.razor
@@ -0,0 +1,39 @@
+@using Xunit
+@inherits FluentUITestContext
+@code
+{
+ public OverlayServiceDisabledTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents(options => options.UseGlobalOverlay = false);
+
+ DialogService = Services.GetRequiredService();
+ DialogProvider = Render();
+ }
+
+ public IDialogService DialogService { get; }
+
+ public IRenderedComponent DialogProvider { get; }
+
+ [Fact]
+ public async Task DialogService_ShowOverlayAsync_WhenGlobalOverlayDisabled_ThrowsInvalidOperationException()
+ {
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await DialogService.ShowOverlayAsync(options => options.Text = "Should fail");
+ });
+
+ Assert.Contains("global overlay is disabled", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task DialogService_HideOverlayAsync_WhenGlobalOverlayDisabled_ThrowsInvalidOperationException()
+ {
+ var ex = await Assert.ThrowsAsync(async () =>
+ {
+ await DialogService.HideOverlayAsync();
+ });
+
+ Assert.Contains("global overlay is disabled", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/tests/Core/Components/Dialog/Overlay/OverlayServiceTests.razor b/tests/Core/Components/Dialog/Overlay/OverlayServiceTests.razor
index bdb7752937..e341f4d2a9 100644
--- a/tests/Core/Components/Dialog/Overlay/OverlayServiceTests.razor
+++ b/tests/Core/Components/Dialog/Overlay/OverlayServiceTests.razor
@@ -1,4 +1,4 @@
-@using Xunit
+@using Xunit
@inherits FluentUITestContext
@code
{
@@ -179,6 +179,20 @@
Assert.Equal(Microsoft.FluentUI.AspNetCore.Components.DialogService.GlobalOverlayId, invocation.Arguments[0]);
}
+ [Fact]
+ public async Task DialogService_HideOverlayAsync_WhenJsInteropThrows_PropagatesException()
+ {
+ // Arrange
+ JSInterop.SetupVoid("Microsoft.FluentUI.Blazor.Components.Overlay.Close", _ => true)
+ .SetException(new InvalidOperationException("close failed"));
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ {
+ await DialogService.HideOverlayAsync();
+ });
+ }
+
[Fact]
public async Task DialogService_ShowOverlayAsync_WhenProviderNotAvailable_ThrowsFluentServiceProviderException()
{
@@ -192,6 +206,19 @@
});
}
+ [Fact]
+ public async Task DialogService_HideOverlayAsync_WhenProviderNotAvailable_ThrowsFluentServiceProviderException()
+ {
+ // Arrange
+ DialogProvider.Instance.UpdateId(null);
+
+ // Act & Assert
+ await Assert.ThrowsAsync>(async () =>
+ {
+ await DialogService.HideOverlayAsync();
+ });
+ }
+
[Fact]
public void DialogService_ShowOverlayAsync_GlobalOverlay_IsRegisteredAtConstruction()
{
diff --git a/tests/Core/Components/Forms/EditFormTests.razor b/tests/Core/Components/Forms/EditFormTests.razor
new file mode 100644
index 0000000000..5f474964eb
--- /dev/null
+++ b/tests/Core/Components/Forms/EditFormTests.razor
@@ -0,0 +1,459 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components.Forms
+
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ public EditFormTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ private sealed class TestModel
+ {
+ [Required]
+ public string? Name { get; set; }
+
+ [Range(1, 100)]
+ public int Age { get; set; }
+ }
+
+ ///
+ /// Tests that FocusLost property defaults to false on component creation.
+ /// This tests the base IFluentField.FocusLost property implementation.
+ ///
+ [Fact]
+ public void FluentInputBase_FocusLost_DefaultIsFalse()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.False(textInput.Instance.FocusLost);
+ }
+
+ ///
+ /// Tests that IFluentField properties can be set and retrieved on a derived component.
+ /// This validates the base class parameter bindings for Label, Disabled, Required, Message, MessageState, and LabelPosition.
+ ///
+ [Fact]
+ public void FluentInputBase_IFluentField_AllPropertiesSetAndRetrieved()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+ var instance = textInput.Instance;
+
+ // Assert
+ Assert.Equal("Test Label", instance.Label);
+ Assert.True(instance.Disabled);
+ Assert.True(instance.Required);
+ Assert.Equal("This field is required", instance.Message);
+ Assert.Equal(MessageState.Error, instance.MessageState);
+ Assert.Equal(LabelPosition.Above, instance.LabelPosition);
+ }
+
+ ///
+ /// Tests that IFluentComponentBase properties can be set and retrieved.
+ /// This validates Class, Style, Margin, Padding, and Data parameter bindings.
+ ///
+ [Fact]
+ public void FluentInputBase_IFluentComponentBase_AllPropertiesSetAndRetrieved()
+ {
+ // Arrange
+ var customData = new { userId = 123, role = "admin" };
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+ var instance = textInput.Instance;
+
+ // Assert
+ Assert.Equal("my-custom-class", instance.Class);
+ Assert.Equal("color: red; font-weight: bold;", instance.Style);
+ Assert.Equal("large", instance.Margin);
+ Assert.Equal("medium", instance.Padding);
+ Assert.NotNull(instance.Data);
+ Assert.Same(customData, instance.Data);
+ }
+
+ ///
+ /// Tests that the Autofocus parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_Autofocus_IsSet()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.True(textInput.Instance.Autofocus);
+ }
+
+ ///
+ /// Tests that the AriaLabel parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_AriaLabel_IsSet()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.Equal("Enter your name here", textInput.Instance.AriaLabel);
+ }
+
+ ///
+ /// Tests that the Name parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_Name_IsSet()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.Equal("userNameField", textInput.Instance.Name);
+ }
+
+ ///
+ /// Tests that the ReadOnly parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_ReadOnly_IsSet()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.True(textInput.Instance.ReadOnly);
+ }
+
+ ///
+ /// Tests that the Id parameter is auto-generated if not provided.
+ ///
+ [Fact]
+ public void FluentInputBase_Id_AutoGeneratedWhenNotProvided()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.NotNull(textInput.Instance.Id);
+ Assert.NotEmpty(textInput.Instance.Id);
+ }
+
+ ///
+ /// Tests that a custom Id can be set and retrieved.
+ ///
+ [Fact]
+ public void FluentInputBase_Id_CustomValueUsedWhenProvided()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.Equal("custom-input-id", textInput.Instance.Id);
+ }
+
+ ///
+ /// Tests that the LabelTemplate parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_LabelTemplate_IsSet()
+ {
+ // Arrange
+ RenderFragment labelTemplate = @Custom Label;
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.NotNull(textInput.Instance.LabelTemplate);
+ Assert.Same(labelTemplate, textInput.Instance.LabelTemplate);
+ }
+
+ ///
+ /// Tests that the MessageTemplate parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_MessageTemplate_IsSet()
+ {
+ // Arrange
+ RenderFragment messageTemplate = @Error occurred;
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.NotNull(textInput.Instance.MessageTemplate);
+ Assert.Same(messageTemplate, textInput.Instance.MessageTemplate);
+ }
+
+ ///
+ /// Tests that the LabelWidth parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_LabelWidth_IsSet()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.Equal("150px", textInput.Instance.LabelWidth);
+ }
+
+ ///
+ /// Tests that the MessageCondition parameter is properly set and callable.
+ ///
+ [Fact]
+ public void FluentInputBase_MessageCondition_IsSetAndCallable()
+ {
+ // Arrange
+ Func condition = field => field.MessageState == MessageState.Error;
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+ var retrieved = textInput.Instance.MessageCondition;
+ var result = retrieved?.Invoke(textInput.Instance) ?? false;
+
+ // Assert
+ Assert.NotNull(retrieved);
+ Assert.Same(condition, retrieved);
+ Assert.True(result);
+ }
+
+ ///
+ /// Tests that the LabelInfo parameter is properly set and retrievable.
+ ///
+ [Fact]
+ public void FluentInputBase_LabelInfo_IsSet()
+ {
+ // Arrange
+ var labelInfo = new FluentUI.AspNetCore.Components.LabelInfo();
+
+ // Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.NotNull(textInput.Instance.LabelInfo);
+ Assert.Same(labelInfo, textInput.Instance.LabelInfo);
+ }
+
+ ///
+ /// Tests SetParametersAsync with a valid parameter dictionary.
+ /// This validates the parameter validation in the base class.
+ ///
+ [Fact]
+ public void FluentInputBase_SetParametersAsync_WithValidParameters()
+ {
+ // Arrange & Act
+ var testModel = new TestModel { Name = "Test" };
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent().Instance;
+
+ // Assert - verify component was created successfully with parameters
+ Assert.NotNull(textInput);
+ Assert.Equal("Test Label", textInput.Label);
+ }
+
+ ///
+ /// Tests that multiple parameters can be combined and work together.
+ /// This validates that the base class properly integrates all parameters.
+ ///
+ [Fact]
+ public void FluentInputBase_MultipleParameters_WorkTogether()
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+ var instance = textInput.Instance;
+
+ // Assert all properties are correctly set
+ Assert.Equal("combo-test", instance.Id);
+ Assert.Equal("User Input", instance.Label);
+ Assert.Equal("Enter user input", instance.AriaLabel);
+ Assert.Equal("userInput", instance.Name);
+ Assert.Equal("form-input", instance.Class);
+ Assert.Equal("border: 2px solid blue;", instance.Style);
+ Assert.Equal("small", instance.Margin);
+ Assert.Equal("medium", instance.Padding);
+ Assert.True(instance.Required);
+ Assert.False(instance.Disabled);
+ Assert.False(instance.ReadOnly);
+ Assert.True(instance.Autofocus);
+ Assert.Equal("This field is required", instance.Message);
+ Assert.Equal(MessageState.Error, instance.MessageState);
+ Assert.Equal(LabelPosition.Above, instance.LabelPosition);
+ Assert.Equal("100px", instance.LabelWidth);
+ }
+
+ ///
+ /// Tests that the component renders properly with different MessageState values.
+ ///
+ [Theory]
+ [InlineData(MessageState.Error)]
+ [InlineData(MessageState.Success)]
+ [InlineData(MessageState.Warning)]
+ public void FluentInputBase_MessageState_VariousValues(MessageState state)
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.Equal(state, textInput.Instance.MessageState);
+ }
+
+ ///
+ /// Tests that the component renders properly with different LabelPosition values.
+ ///
+ [Theory]
+ [InlineData(LabelPosition.Above)]
+ [InlineData(LabelPosition.Before)]
+ [InlineData(LabelPosition.After)]
+ public void FluentInputBase_LabelPosition_VariousValues(LabelPosition position)
+ {
+ // Arrange & Act
+ var cut = Render(
+ @
+
+
+ );
+
+ var textInput = cut.FindComponent();
+
+ // Assert
+ Assert.Equal(position, textInput.Instance.LabelPosition);
+ }
+}
diff --git a/tests/Core/Components/Paginator/FluentPaginatorTests.razor b/tests/Core/Components/Paginator/FluentPaginatorTests.razor
index 05d5b31239..557e39730b 100644
--- a/tests/Core/Components/Paginator/FluentPaginatorTests.razor
+++ b/tests/Core/Components/Paginator/FluentPaginatorTests.razor
@@ -1,6 +1,4 @@
-@using Microsoft.FluentUI.AspNetCore.Components.Utilities
-@using Xunit
-@using Microsoft.FluentUI.AspNetCore.Components.Infrastructure
+@using Xunit
@using Microsoft.AspNetCore.Components.Web
@inherits FluentUITestContext
diff --git a/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor b/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor
new file mode 100644
index 0000000000..ea194acfa1
--- /dev/null
+++ b/tests/Core/Components/Wizard/FluentWizardAdvancedTests.razor
@@ -0,0 +1,303 @@
+@using System.ComponentModel.DataAnnotations
+@using Microsoft.AspNetCore.Components
+@using Microsoft.AspNetCore.Components.Forms
+@using Xunit;
+@inherits FluentUITestContext
+
+@code
+{
+ public FluentWizardAdvancedTests()
+ {
+ JSInterop.Mode = JSRuntimeMode.Loose;
+ Services.AddFluentUIComponents();
+ }
+
+ private sealed class WizardFormModel
+ {
+ [Required]
+ public string? Name { get; set; }
+ }
+
+ [Fact]
+ public void FluentWizard_StepperSize_InvalidStepperPosition_DoesNotRenderStyle()
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ Content 1
+ Content 2
+
+
+ );
+
+ // Assert: unknown position uses default switch branch => no stepper size style output
+ var stepper = cut.Find("ol");
+ Assert.Null(stepper.GetAttribute("style"));
+ }
+
+ [Fact]
+ public async Task FluentWizard_FinishAsync_WithValidator_UsesEditContextValidation()
+ {
+ // Arrange
+ var finishCalled = false;
+ var model = new WizardFormModel(); // invalid: Name is required
+ var editContext = new EditContext(model);
+ FluentWizard wizard = default!;
+
+ var cut = Render(
+ @
+
+
+
+
+
+
+
+
+
+ );
+
+ // Act 1: invalid model => FinishAsync(validate=true) must not invoke OnFinish
+ await cut.InvokeAsync(() => wizard.FinishAsync(validateEditContexts: true));
+
+ // Assert 1
+ Assert.False(finishCalled);
+
+ // Act 2: make model valid, then finish again
+ model.Name = "John";
+ await cut.InvokeAsync(() => editContext.Validate());
+ await cut.InvokeAsync(() => wizard.FinishAsync(validateEditContexts: true));
+
+ // Assert 2
+ Assert.True(finishCalled);
+
+ // Dispose component tree to execute FluentWizardStepValidator.Dispose unregister path
+ cut.Dispose();
+ }
+
+ [Fact]
+ public void FluentWizardStep_WithoutFluentWizard_ThrowsArgumentException()
+ {
+ // Arrange + Act + Assert
+ var ex = Assert.Throws(() =>
+ {
+ Render(@Content);
+ });
+
+ Assert.Contains("must be included in the FluentWizard", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void FluentWizardStepValidator_OutsideFluentWizardStep_ThrowsInvalidOperationException()
+ {
+ // Arrange + Act + Assert
+ var ex = Assert.Throws(() =>
+ {
+ Render(@);
+ });
+
+ Assert.Contains("must be used inside a FluentWizardStep", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FluentWizardStepValidator_InsideFluentWizardStepWithoutEditForm_ThrowsInvalidOperationException()
+ {
+ // Arrange + Act + Assert
+ var ex = Assert.Throws(() =>
+ {
+ Render(
+ @
+
+
+
+
+
+
+ );
+ });
+
+ Assert.Contains("must be used inside an EditForm", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(StepperPosition.Left, "width: 120px")]
+ [InlineData(StepperPosition.Top, "height: 120px")]
+ public void FluentWizard_StepperSize_WithKnownPosition_RendersExpectedStyle(StepperPosition position, string expectedStyle)
+ {
+ // Arrange && Act
+ var cut = Render(
+ @
+
+ Content 1
+ Content 2
+
+
+ );
+
+ // Assert
+ var stepper = cut.Find("ol");
+ Assert.Equal(expectedStyle, stepper.GetAttribute("style"));
+ }
+
+ [Fact]
+ public async Task FluentWizard_SetParametersAsync_NormalizesOutOfRangeValue()
+ {
+ // Arrange
+ var cut = Render(
+ @
+
+ Content 1
+ Content 2
+ Content 3
+
+
+ );
+
+ var wizard = cut.FindComponent().Instance;
+
+ // Act: above max => normalize to last index
+ await cut.InvokeAsync(() => wizard.SetParametersAsync(ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(FluentWizard.Value)] = 99,
+ })));
+
+ // Assert
+ var stepsAfterHighValue = cut.FindAll("li");
+ Assert.Equal("current", stepsAfterHighValue[2].GetAttribute("status"));
+
+ // Act: below min => normalize to zero
+ await cut.InvokeAsync(() => wizard.SetParametersAsync(ParameterView.FromDictionary(new Dictionary
+ {
+ [nameof(FluentWizard.Value)] = -5,
+ })));
+
+ // Assert
+ var stepsAfterLowValue = cut.FindAll("li");
+ Assert.Equal("current", stepsAfterLowValue[0].GetAttribute("status"));
+ }
+
+ [Fact]
+ public async Task FluentWizardStep_RegisterEditFormAndContext_FinishAndDeferredNavigation_CoversValidationBranches()
+ {
+ // Arrange
+ var model = new WizardFormModel();
+ var editContext = new EditContext(model);
+ var validSubmitCount = 0;
+ var invalidSubmitCount = 0;
+
+ FluentWizard wizard = default!;
+ FluentWizardStep firstStep = default!;
+
+ var cut = Render(
+ @
+
+
+
+
+
+
+
+ Content 2
+
+
+ );
+
+ var editForm = cut.FindComponent().Instance;
+ firstStep.RegisterEditFormAndContext(editForm, editContext);
+
+ // Act: invalid model
+ var invalidBeforeFinish = await cut.InvokeAsync(() => firstStep.ValidateEditContexts());
+ await cut.InvokeAsync(() => wizard.FinishAsync(validateEditContexts: true));
+
+ // Assert invalid path
+ Assert.False(invalidBeforeFinish);
+ Assert.Equal(0, validSubmitCount);
+ Assert.Equal(1, invalidSubmitCount);
+
+ // Act: valid model
+ model.Name = "John";
+ await cut.InvokeAsync(() => editContext.Validate());
+ await cut.InvokeAsync(() => wizard.FinishAsync(validateEditContexts: true));
+
+ // Assert valid path
+ Assert.Equal(1, validSubmitCount);
+ Assert.Equal(1, invalidSubmitCount);
+
+ // Act: deferred loading should clear registered forms/contexts when leaving step 1
+ await cut.InvokeAsync(() => wizard.GoToStepAsync(1));
+
+ // Assert clear path
+ model.Name = null;
+ var validationAfterClear = await cut.InvokeAsync(() => firstStep.ValidateEditContexts());
+ Assert.True(validationAfterClear);
+ }
+
+ [Fact]
+ public void FluentWizardStep_StepTemplate_RendersCustomTemplate()
+ {
+ // Arrange
+ RenderFragment template = args =>
+ @Template @(args.Index);
+
+ // Act
+ var cut = Render(
+ @
+
+
+ Content 1
+
+ Content 2
+
+
+ );
+
+ // Assert
+ Assert.Contains("Template 0", cut.Markup);
+ Assert.Contains("custom-step", cut.Markup);
+ }
+
+ [Fact]
+ public void FluentWizardStep_OnClickHandler_RespectsSequenceAndCurrentStepRules()
+ {
+ // Arrange
+ var value = 0;
+ var cut = Render(
+ @
+
+ Content 1
+ Content 2
+ Content 3
+
+
+ );
+
+ var steps = cut.FindAll("li");
+
+ // Act: current step is not clickable
+ steps[0].Click();
+
+ // Assert
+ Assert.Equal(0, value);
+
+ // Act: in visited mode, unvisited future step is not clickable
+ steps[2].Click();
+
+ // Assert
+ Assert.Equal(0, value);
+
+ // Act: visit next step, then go back by clicking already visited previous step
+ var nextButton = cut.FindAll("fluent-button").First(b => b.TextContent.Trim() == "Next");
+ nextButton.Click();
+ Assert.Equal(1, value);
+
+ steps[0].Click();
+
+ // Assert: visited step click works
+ Assert.Equal(0, value);
+ Assert.Contains("Summary 3", cut.Markup);
+ }
+}
diff --git a/tests/Core/_StartCodeCoverage.cmd b/tests/Core/_StartCodeCoverage.cmd
deleted file mode 100644
index 7de08ebdf9..0000000000
--- a/tests/Core/_StartCodeCoverage.cmd
+++ /dev/null
@@ -1,31 +0,0 @@
-echo off
-
-REM 0. Include the NuGet Package "coverlet.msbuild" in the UnitTests project.
-REM 1. Install tools:
-REM $:\> dotnet tool install --global coverlet.console
-REM $:\> dotnet tool install --global dotnet-reportgenerator-globaltool
-REM
-REM Use this command to list existing installed tools:
-REM $:\> dotnet tool list --global
-REM
-REM 2. Start a code coverage in the UnitTests project:
-REM $:\> dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
-REM
-REM 3. Display the Coverage Report:
-REM $:\> reportgenerator "-reports:coverage.cobertura.xml" "-targetdir:C:\Temp\FluentUI\Coverage" -reporttypes:HtmlInline_AzurePipelines
-REM $:\> explorer C:\Temp\Coverage\index.html
-REM
-REM 4. Add /noopen to skip opening the report in a browser (useful for refreshing an open window)
-REM $:\> _StartCodeCoverage.cmd /noopen
-
-
-echo on
-cls
-
-dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura
-reportgenerator "-reports:coverage.cobertura.xml" "-targetdir:C:\Temp\FluentUI\Coverage" -reporttypes:HtmlInline_AzurePipelines -classfilters:"-Microsoft.FluentUI.AspNetCore.Components.DesignTokens.*" -filefilters:"-*RegexGenerator.g.cs" riskHotspotsAnalysisThresholds:metricThresholdForCrapScore=30 riskHotspotsAnalysisThresholds:metricThresholdForCyclomaticComplexity=30 minimumCoverageThresholds:lineCoverage=98
-
-echo off
-
-if "%~1" neq "/noopen" start "" "C:\Temp\FluentUI\Coverage\index.htm"
-
diff --git a/tests/Integration/Components.IntegrationTests.csproj b/tests/Integration/Components.IntegrationTests.csproj
index 04eb9a87e1..768f2185b7 100644
--- a/tests/Integration/Components.IntegrationTests.csproj
+++ b/tests/Integration/Components.IntegrationTests.csproj
@@ -27,6 +27,7 @@
+
diff --git a/tests/Tools/McpServer.Tests/GlobalUsings.cs b/tests/McpServer/GlobalUsings.cs
similarity index 100%
rename from tests/Tools/McpServer.Tests/GlobalUsings.cs
rename to tests/McpServer/GlobalUsings.cs
diff --git a/tests/Tools/McpServer.Tests/Helpers/ToolOutputHelperTests.cs b/tests/McpServer/Helpers/ToolOutputHelperTests.cs
similarity index 100%
rename from tests/Tools/McpServer.Tests/Helpers/ToolOutputHelperTests.cs
rename to tests/McpServer/Helpers/ToolOutputHelperTests.cs
diff --git a/tests/Tools/McpServer.Tests/IntegrationTests.cs b/tests/McpServer/IntegrationTests.cs
similarity index 100%
rename from tests/Tools/McpServer.Tests/IntegrationTests.cs
rename to tests/McpServer/IntegrationTests.cs
diff --git a/tests/Tools/McpServer.Tests/JsonDocumentationFinderTests.cs b/tests/McpServer/JsonDocumentationFinderTests.cs
similarity index 100%
rename from tests/Tools/McpServer.Tests/JsonDocumentationFinderTests.cs
rename to tests/McpServer/JsonDocumentationFinderTests.cs
diff --git a/tests/Tools/McpServer.Tests/Microsoft.FluentUI.AspNetCore.McpServer.Tests.csproj b/tests/McpServer/McpServer.Tests.csproj
similarity index 89%
rename from tests/Tools/McpServer.Tests/Microsoft.FluentUI.AspNetCore.McpServer.Tests.csproj
rename to tests/McpServer/McpServer.Tests.csproj
index 3f4f11c944..c092217ce9 100644
--- a/tests/Tools/McpServer.Tests/Microsoft.FluentUI.AspNetCore.McpServer.Tests.csproj
+++ b/tests/McpServer/McpServer.Tests.csproj
@@ -25,12 +25,12 @@
-
+
- $(MSBuildThisFileDirectory)..\..\..\src\Tools\McpServer\FluentUIComponentsDocumentation.json
+ $(MSBuildThisFileDirectory)..\..\src\Tools\McpServer\FluentUIComponentsDocumentation.json
-
-
- $([System.String]::Copy('%(FileName)').Split('.')[0])
- %(ParentFile).cs
-
-
-
- $([System.String]::Copy('%(FileName)').Split('.')[0])
- %(ParentFile).cs
-
-
-
+
+
+
+
+
+ $([System.String]::Copy('%(FileName)').Split('.')[0])
+ %(ParentFile).cs
+
+
+
+ $([System.String]::Copy('%(FileName)').Split('.')[0])
+ %(ParentFile).cs
+
+
+
*/
///
@@ -65,7 +65,7 @@ public static void Verify(this IRenderedComponent? actual,
{
return;
}
-
+
// Valid?
ArgumentNullException.ThrowIfNull(filename, nameof(filename));
ArgumentNullException.ThrowIfNull(memberName, nameof(memberName));
diff --git a/tests/Core/Verify/FluentAssertExtensions.cs b/tests/Tools/Verify/FluentAssertExtensions.cs
similarity index 100%
rename from tests/Core/Verify/FluentAssertExtensions.cs
rename to tests/Tools/Verify/FluentAssertExtensions.cs
diff --git a/tests/Core/Verify/FluentAssertOptions.cs b/tests/Tools/Verify/FluentAssertOptions.cs
similarity index 100%
rename from tests/Core/Verify/FluentAssertOptions.cs
rename to tests/Tools/Verify/FluentAssertOptions.cs
diff --git a/tests/Core/Verify/FluentUITestContext.cs b/tests/Tools/Verify/FluentUITestContext.cs
similarity index 100%
rename from tests/Core/Verify/FluentUITestContext.cs
rename to tests/Tools/Verify/FluentUITestContext.cs
diff --git a/tests/_StartCodeCoverage.ps1 b/tests/_StartCodeCoverage.ps1
new file mode 100644
index 0000000000..23cc288768
--- /dev/null
+++ b/tests/_StartCodeCoverage.ps1
@@ -0,0 +1,184 @@
+
+# ---------------------------------------------------------------------------
+# Code Coverage for Components (Core) and Charts test projects combined.
+# ---------------------------------------------------------------------------
+#
+# Prerequisites (install once, globally):
+# dotnet tool install --global dotnet-reportgenerator-globaltool
+#
+# Use this command to list installed tools:
+# dotnet tool list --global
+#
+# How it works:
+# Both test projects are run with the built-in "XPlat Code Coverage"
+# DataCollector (provided by coverlet.collector). Each run writes its
+# coverage.cobertura.xml under tests\TestResults\{Core|Charts}\.
+# ReportGenerator then merges all cobertura files in one step and outputs
+# the merged report to tests\TestResults\Report.
+#
+# A stamp file is written to TestResults\_stamps\ after each successful
+# test run. On the next invocation, PowerShell checks whether any .cs,
+# .razor, or .csproj file under the relevant source and test directories
+# is newer than the stamp. If nothing changed the project is skipped,
+# and the previous coverage.cobertura.xml is reused for the merged report.
+#
+# Note: TestResults\ should be in .gitignore so stamps are local-only.
+#
+# Usage:
+# _StartCodeCoverage.ps1 - run changed projects and open report
+# _StartCodeCoverage.ps1 /noopen - run changed projects, skip browser
+# _StartCodeCoverage.ps1 /force - ignore stamps, always re-run all tests
+
+param(
+ [switch]$Force,
+ [switch]$NoOpen,
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$RemainingArgs
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+foreach ($arg in $RemainingArgs) {
+ switch ($arg.ToLowerInvariant()) {
+ '/force' { $Force = $true }
+ '/noopen' { $NoOpen = $true }
+ }
+}
+
+$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$resultsDir = Join-Path $scriptDir 'TestResults'
+$stampsDir = Join-Path $resultsDir '_stamps'
+
+if (-not (Test-Path $stampsDir)) {
+ New-Item -ItemType Directory -Path $stampsDir | Out-Null
+}
+
+function Test-ProjectNeedsRun {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$StampPath,
+ [Parameter(Mandatory = $true)]
+ [string[]]$Directories
+ )
+
+ if (-not (Test-Path $StampPath)) {
+ return $true
+ }
+
+ $stampTime = (Get-Item $StampPath).LastWriteTime
+
+ foreach ($dir in $Directories) {
+ if (-not (Test-Path $dir)) {
+ continue
+ }
+
+ $changedFile = Get-ChildItem -Path $dir -Recurse -File -Include '*.cs', '*.razor', '*.csproj' -ErrorAction SilentlyContinue |
+ Where-Object { $_.LastWriteTime -gt $stampTime } |
+ Select-Object -First 1
+
+ if ($null -ne $changedFile) {
+ return $true
+ }
+ }
+
+ return $false
+}
+
+Clear-Host
+Write-Host '=== Determining which projects need to run ==='
+
+$coreStamp = Join-Path $stampsDir 'core.stamp'
+$chartsStamp = Join-Path $stampsDir 'charts.stamp'
+
+$coreDirs = @(
+ (Join-Path $scriptDir '..\src\Core'),
+ (Join-Path $scriptDir 'Core'),
+ (Join-Path $scriptDir 'Shared')
+)
+
+$chartsDirs = @(
+ (Join-Path $scriptDir '..\src\Charts'),
+ (Join-Path $scriptDir 'Charts'),
+ (Join-Path $scriptDir 'Shared')
+)
+
+$coreRun = $true
+$chartsRun = $true
+
+if (-not $Force) {
+ $coreRun = Test-ProjectNeedsRun -StampPath $coreStamp -Directories $coreDirs
+ $chartsRun = Test-ProjectNeedsRun -StampPath $chartsStamp -Directories $chartsDirs
+}
+
+Write-Host
+if (-not $coreRun) {
+ Write-Host ' Core - SKIPPED (no changes detected)'
+}
+if (-not $chartsRun) {
+ Write-Host ' Charts - SKIPPED (no changes detected)'
+}
+
+if (-not $coreRun -and -not $chartsRun) {
+ Write-Host
+ Write-Host 'Nothing to run. Use /force to override.'
+}
+else {
+ if ($coreRun) {
+ $coreResults = Join-Path $resultsDir 'Core'
+ if (Test-Path $coreResults) {
+ Remove-Item -Path $coreResults -Recurse -Force
+ }
+
+ Write-Host
+ Write-Host '=== Running Core component tests with coverage ==='
+
+ & dotnet test (Join-Path $scriptDir 'Core\Components.Tests.csproj') `
+ '--collect:XPlat Code Coverage' `
+ '--results-directory' $coreResults `
+ '--' 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include=[Microsoft.FluentUI.AspNetCore.Components]*'
+
+ if ($LASTEXITCODE -eq 0) {
+ New-Item -ItemType File -Path $coreStamp -Force | Out-Null
+ }
+ }
+
+ if ($chartsRun) {
+ $chartsResults = Join-Path $resultsDir 'Charts'
+ if (Test-Path $chartsResults) {
+ Remove-Item -Path $chartsResults -Recurse -Force
+ }
+
+ Write-Host
+ Write-Host '=== Running Charts component tests with coverage ==='
+
+ & dotnet test (Join-Path $scriptDir 'Charts\Components.Charts.Tests.csproj') `
+ '--collect:XPlat Code Coverage' `
+ '--results-directory' $chartsResults `
+ '--' 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include=[Microsoft.FluentUI.AspNetCore.Components.Charts]*'
+
+ if ($LASTEXITCODE -eq 0) {
+ New-Item -ItemType File -Path $chartsStamp -Force | Out-Null
+ }
+ }
+}
+
+Write-Host
+Write-Host '=== Merging coverage reports ==='
+
+& reportgenerator `
+ "-reports:$resultsDir\**\coverage.cobertura.xml" `
+ "-targetdir:$resultsDir\Report" `
+ '-reporttypes:HtmlInline_AzurePipelines' `
+ '-assemblyfilters:+Microsoft.FluentUI.AspNetCore.Components;+Microsoft.FluentUI.AspNetCore.Components.Charts' `
+ '-classfilters:-Microsoft.FluentUI.AspNetCore.Components.DesignTokens.*' `
+ '-filefilters:-*RegexGenerator.g.cs' `
+ 'riskHotspotsAnalysisThresholds:metricThresholdForCrapScore=30' `
+ 'riskHotspotsAnalysisThresholds:metricThresholdForCyclomaticComplexity=30' `
+ 'minimumCoverageThresholds:lineCoverage=98'
+
+Write-Host
+$reportPath = Join-Path $resultsDir 'Report\index.htm'
+if (-not $NoOpen -and (Test-Path $reportPath)) {
+ Start-Process $reportPath
+}