File: Controls\ChartFiltersTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Components.Tests\Aspire.Dashboard.Components.Tests.csproj (Aspire.Dashboard.Components.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Otlp.Model;
using Aspire.Dashboard.Components.Tests.Shared;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
 
namespace Aspire.Dashboard.Components.Tests.Controls;
 
[UseCulture("en-US")]
public class ChartFiltersTests : DashboardTestContext
{
    [Fact]
    public void AreAllValuesSelected_SetFalse_ClearsOnlyWhenAllValuesAreStored()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
        dimensionFilter.SetSelectedValues(dimensionFilter.Values);
 
        Assert.True(dimensionFilter.AreAllValuesSelected);
 
        dimensionFilter.AreAllValuesSelected = false;
 
        Assert.Empty(dimensionFilter.SelectedValues);
    }
 
    [Fact]
    public void AreAllValuesSelected_SetFalse_DoesNotClearWhenPartiallySelected()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
        dimensionFilter.SetSelectedValues([dimensionFilter.Values[0]]);
 
        Assert.Null(dimensionFilter.AreAllValuesSelected);
 
        dimensionFilter.AreAllValuesSelected = false;
 
        Assert.Single(dimensionFilter.SelectedValues);
        Assert.Equal("GET", dimensionFilter.SelectedValues.First().Value);
    }
 
    [Fact]
    public void AreAllValuesSelected_SetTrue_SelectsAllValues()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
        dimensionFilter.SetSelectedValues([dimensionFilter.Values[0]]);
 
        dimensionFilter.AreAllValuesSelected = true;
 
        Assert.Equal(dimensionFilter.Values.Count, dimensionFilter.SelectedValues.Count);
        Assert.True(dimensionFilter.AreAllValuesSelected);
    }
 
    [Fact]
    public void OnTagSelectionChanged_ReplacesSnapshotAndRemovesValue()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
        var getValue = new DimensionValueViewModel { Text = "GET", Value = "GET", };
        var postValue = new DimensionValueViewModel { Text = "POST", Value = "POST", };
        dimensionFilter.Values.Add(getValue);
        dimensionFilter.Values.Add(postValue);
        dimensionFilter.SetSelectedValues([getValue, postValue]);
        var selectedValuesSnapshot = dimensionFilter.SelectedValues;
 
        dimensionFilter.OnTagSelectionChanged(getValue, isChecked: false);
 
        Assert.Single(dimensionFilter.SelectedValues);
        Assert.Contains(postValue, dimensionFilter.SelectedValues);
        Assert.True(selectedValuesSnapshot.SetEquals([getValue, postValue]));
    }
 
    [Fact]
    public void Render_AllValuesSelected_ShowsAllState()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.AreAllValuesSelected = true;
 
        var cut = RenderChartFilters(dimensionFilter);
 
        Assert.DoesNotContain("(None)", cut.Markup);
        Assert.Single(cut.FindAll(".chart-filter-button-container"));
        Assert.Contains("aria-label=\"All tags\"", cut.Markup);
        Assert.NotNull(cut.Find(".dimension-popup-container"));
        Assert.All(cut.FindAll(".dimension-popup fluent-field"), field => Assert.Contains("aspire-checkbox", field.ClassList));
        Assert.Equal("0", cut.Find(".dimension-overflow").GetAttribute("threshold"));
        var overflowItems = cut.FindAll(".dimension-overflow > div:not(.fluent-overflow-more)");
        Assert.Equal("ellipsis", overflowItems[0].GetAttribute("behavior"));
        Assert.Contains("dimension-overflow-ellipsis", overflowItems[0].ClassList);
        Assert.All(overflowItems.Skip(1), item => Assert.Null(item.GetAttribute("behavior")));
        Assert.All(overflowItems.Skip(1), item => Assert.DoesNotContain("dimension-overflow-ellipsis", item.ClassList));
    }
 
    [Fact]
    public void Render_MoreThanMaxTags_RendersBoundedPayloadAndHighlightsSelectedOverflow()
    {
        SetupChartFilters();
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" };
        for (var i = 0; i < 30; i++)
        {
            dimensionFilter.Values.Add(new DimensionValueViewModel { Text = i.ToString(), Value = i.ToString() });
        }
        dimensionFilter.SetSelectedValues([dimensionFilter.Values[25]]);
 
        var cut = RenderChartFilters(dimensionFilter);
 
        var overflow = cut.Find(".dimension-overflow");
        var overflowItems = cut.FindAll(".dimension-overflow > div:not(.fluent-overflow-more)");
        var moreButton = cut.Find(".dimension-overflow .fluent-overflow-more .filter-value-tag");
        Assert.Equal("10", overflow.GetAttribute("pre-overflow-count"));
        Assert.Equal(20, overflowItems.Count);
        Assert.Equal("+10", moreButton.TextContent.Trim());
        Assert.Contains("included-in-filters", moreButton.ClassList);
    }
 
    [Fact]
    public void Click_FilterValueOverflow_OpensPopover()
    {
        SetupChartFilters();
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" };
        for (var i = 0; i < 30; i++)
        {
            dimensionFilter.Values.Add(new DimensionValueViewModel { Text = i.ToString(), Value = i.ToString() });
        }
        var cut = RenderChartFilters(dimensionFilter);
 
        cut.Find(".dimension-overflow .fluent-overflow-more .filter-value-tag").Click();
 
        Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened"));
    }
 
    [Fact]
    public void Render_PartiallySelectedValues_ShowsIndeterminateAllCheckbox()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.SetSelectedValues([dimensionFilter.Values[0]]);
 
        var cut = RenderChartFilters(dimensionFilter);
        var allCheckbox = cut.FindComponents<FluentCheckbox>()[0].Instance;
 
        Assert.False(allCheckbox.Value);
        Assert.Null(allCheckbox.CheckState);
    }
 
    [Fact]
    public async Task SelectionChanged_HighlightsFilterButtonUntilAllValuesSelected()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.AreAllValuesSelected = true;
        var cut = RenderChartFilters(dimensionFilter);
        var popover = cut.FindComponent<ChartFilterPopover>();
        var buttonId = cut.Find(".chart-filter-button").Id;
        var checkboxes = popover.FindComponents<FluentCheckbox>();
 
        AssertButtonAppearance(highlighted: false);
 
        await cut.InvokeAsync(() => checkboxes[1].Instance.ValueChanged.InvokeAsync(false));
        AssertButtonAppearance(highlighted: true);
 
        await cut.InvokeAsync(() => checkboxes[0].Instance.CheckStateChanged.InvokeAsync(true));
        AssertButtonAppearance(highlighted: false);
 
        await cut.InvokeAsync(() => checkboxes[0].Instance.CheckStateChanged.InvokeAsync(false));
        AssertButtonAppearance(highlighted: true);
 
        await cut.InvokeAsync(() => checkboxes[0].Instance.CheckStateChanged.InvokeAsync(true));
        AssertButtonAppearance(highlighted: false);
 
        void AssertButtonAppearance(bool highlighted)
        {
            cut.WaitForAssertion(() =>
            {
                var button = cut.Find(".chart-filter-button");
                Assert.Equal(buttonId, button.Id);
                Assert.Equal(highlighted ? "primary" : "transparent", button.GetAttribute("appearance"));
                Assert.Equal(highlighted ? "Filtered tags" : "All tags", button.GetAttribute("aria-label"));
                var icon = cut.FindComponent<FluentIcon<Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size20.Filter>>().Instance;
                Assert.Equal(Color.Custom, icon.Color);
                Assert.Equal(highlighted ? "currentColor" : "var(--colorBrandForeground1)", icon.CustomColor);
            });
        }
    }
 
    [Fact]
    public void Click_FilterButton_KeepsStableAnchorAndOpensPopover()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        var cut = RenderChartFilters(dimensionFilter);
        var button = cut.Find(".chart-filter-button");
        var buttonId = button.Id;
 
        button.Click();
 
        Assert.Equal(buttonId, cut.Find(".chart-filter-button").Id);
        var popover = cut.Find("fluent-popover-b");
        Assert.Equal(buttonId, popover.GetAttribute("anchor-id"));
        Assert.Equal("true", popover.GetAttribute("opened"));
        Assert.Contains("chart-filter-popover", popover.ClassList);
    }
 
    [Fact]
    public void Render_NamesWithSameSanitizedId_HaveDistinctPopoverAnchors()
    {
        SetupChartFilters();
        var firstFilter = new DimensionFilterViewModel { Name = "foo.bar" };
        var secondFilter = new DimensionFilterViewModel { Name = "foo/bar" };
        var cut = RenderChartFilters(firstFilter);
        cut.Render(builder => builder.Add(component => component.DimensionFilters, [firstFilter, secondFilter]));
 
        var buttons = cut.FindAll(".chart-filter-button");
        var popovers = cut.FindAll("fluent-popover-b.chart-filter-popover");
 
        Assert.Equal(2, buttons.Count);
        Assert.Equal(2, popovers.Count);
        Assert.NotEqual(buttons[0].Id, buttons[1].Id);
        Assert.StartsWith("typeFilterButton-foo_bar-", buttons[0].Id);
        Assert.StartsWith("typeFilterButton-foo_bar-", buttons[1].Id);
        Assert.Equal(buttons[0].Id, popovers[0].GetAttribute("anchor-id"));
        Assert.Equal(buttons[1].Id, popovers[1].GetAttribute("anchor-id"));
 
        buttons[1].Click();
 
        Assert.Equal("false", cut.FindAll("fluent-popover-b.chart-filter-popover")[0].GetAttribute("opened"));
        Assert.Equal("true", cut.FindAll("fluent-popover-b.chart-filter-popover")[1].GetAttribute("opened"));
        Assert.Equal(buttons[0].Id, cut.FindAll(".chart-filter-button")[0].Id);
        Assert.Equal(buttons[1].Id, cut.FindAll(".chart-filter-button")[1].Id);
    }
 
    [Fact]
    public void ReplaceFilterSnapshot_KeepsOpenPopoverMounted()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        var cut = RenderChartFilters(dimensionFilter);
        var buttonId = cut.Find(".chart-filter-button").Id;
        var popover = cut.FindComponent<ChartFilterPopover>().Instance;
        cut.Find(".chart-filter-button").Click();
 
        var updatedFilter = CreateDimensionFilter();
        updatedFilter.Values.Add(new DimensionValueViewModel { Text = "PUT", Value = "PUT", });
        updatedFilter.SetSelectedValues(updatedFilter.Values);
        cut.Render(builder => builder.Add(component => component.DimensionFilters, [updatedFilter]));
 
        Assert.Same(popover, cut.FindComponent<ChartFilterPopover>().Instance);
        Assert.Same(updatedFilter, popover.Filter);
        Assert.Equal(buttonId, cut.Find(".chart-filter-button").Id);
        Assert.Equal(buttonId, cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("anchor-id"));
        Assert.Equal("true", cut.Find("fluent-popover-b.chart-filter-popover").GetAttribute("opened"));
        Assert.Equal(4, cut.FindAll(".dimension-popup fluent-field").Count);
    }
 
    [Fact]
    public void Render_FilterValueTags_UseNativeButtons()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.SetSelectedValues([dimensionFilter.Values.Single(v => v.Text == "POST")]);
 
        var cut = RenderChartFilters(dimensionFilter);
        var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
 
        Assert.Equal("BUTTON", getTag.TagName);
        Assert.Equal("button", getTag.GetAttribute("type"));
    }
 
    [Fact]
    public void Click_FilterValueTag_SelectsOnlyClickedValue()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.SetSelectedValues([dimensionFilter.Values.Single(v => v.Text == "POST")]);
        var cut = RenderChartFilters(dimensionFilter);
        var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
 
        getTag.Click(new MouseEventArgs());
 
        var selectedValue = Assert.Single(dimensionFilter.SelectedValues);
        Assert.Equal("GET", selectedValue.Text);
    }
 
    [Fact]
    public void ShiftClick_UnselectedFilterValueTag_AddsClickedValue()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.SetSelectedValues([dimensionFilter.Values.Single(v => v.Text == "POST")]);
        var cut = RenderChartFilters(dimensionFilter);
        var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
 
        getTag.Click(new MouseEventArgs { ShiftKey = true });
 
        Assert.Equal(["GET", "POST"], dimensionFilter.SelectedValues.Select(v => v.Text).Order());
    }
 
    [Fact]
    public void ShiftClick_SelectedFilterValueTag_RemovesClickedValue()
    {
        SetupChartFilters();
        var dimensionFilter = CreateDimensionFilter();
        dimensionFilter.SetSelectedValues(dimensionFilter.Values);
        var cut = RenderChartFilters(dimensionFilter);
        var getTag = cut.FindAll(".filter-value-tag").Single(e => e.TextContent.Trim() == "GET");
 
        getTag.Click(new MouseEventArgs { ShiftKey = true });
 
        var selectedValue = Assert.Single(dimensionFilter.SelectedValues);
        Assert.Equal("POST", selectedValue.Text);
    }
 
    [Fact]
    public void GetOrderedValues_NumericValues_OrdersNumerically()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.status_code" };
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "200", Value = "200", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "404", Value = "404", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "50", Value = "50", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "1000", Value = "1000", });
 
        var ordered = ChartFilterTags.GetOrderedValues(dimensionFilter.Values).Select(v => v.Text).ToList();
 
        Assert.Equal(["50", "200", "404", "1000"], ordered);
    }
 
    [Fact]
    public void GetOrderedValues_TextValues_OrdersAlphabetically()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "DELETE", Value = "DELETE", });
 
        var ordered = ChartFilterTags.GetOrderedValues(dimensionFilter.Values).Select(v => v.Text).ToList();
 
        Assert.Equal(["DELETE", "GET", "POST"], ordered);
    }
 
    private IRenderedComponent<ChartFilters> RenderChartFilters(
        DimensionFilterViewModel dimensionFilter,
        Action<DimensionFilterViewModel>? onDimensionValuesChanged = null)
    {
        return Render<ChartFilters>(builder =>
        {
            builder.Add(p => p.InstrumentType, OtlpInstrumentType.Sum);
            builder.Add(p => p.ShowCount, false);
            builder.Add(p => p.DimensionFilters, [dimensionFilter]);
            if (onDimensionValuesChanged is not null)
            {
                builder.Add(p => p.OnDimensionValuesChanged, EventCallback.Factory.Create(this, onDimensionValuesChanged));
            }
        });
    }
 
    private void SetupChartFilters()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentDataGrid(this);
        FluentUISetupHelpers.SetupFluentCheckbox(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
        MetricsSetupHelpers.SetupChartContainer(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
    }
 
    private static DimensionFilterViewModel CreateDimensionFilter()
    {
        var dimensionFilter = new DimensionFilterViewModel { Name = "http.method" };
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "GET", Value = "GET", });
        dimensionFilter.Values.Add(new DimensionValueViewModel { Text = "POST", Value = "POST", });
 
        return dimensionFilter;
    }
 
}