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 +}