File: Controls\AspireMenuTests.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.Components.Tests.Shared;
using Aspire.Dashboard.Model;
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Xunit;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
 
namespace Aspire.Dashboard.Components.Tests.Controls;
 
public class AspireMenuTests : DashboardTestContext
{
    [Fact]
    public async Task ClickSecondaryAction_DoesNotSelectItemAndRefreshesOpenMenu()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
        FluentUISetupHelpers.SetupFluentButton(this);
 
        var itemClicked = false;
        var secondaryActionClicked = false;
        var item = new MenuButtonItem
        {
            Text = "Historical run",
            Role = MenuItemRole.Radio,
            Checked = false,
            Icon = new Icons.Regular.Size16.Checkmark(),
            SecondaryActionIcon = new Icons.Regular.Size16.Pin(),
            SecondaryActionAriaLabel = "Pin run",
            OnClick = () =>
            {
                itemClicked = true;
                return Task.CompletedTask;
            }
        };
        item.OnSecondaryActionClick = () =>
        {
            secondaryActionClicked = true;
            item.SecondaryActionIcon = new Icons.Filled.Size16.Pin();
            item.SecondaryActionAriaLabel = "Unpin run";
            item.IsSecondaryActionSelected = true;
            return Task.CompletedTask;
        };
 
        var menuHost = RenderComponent<AspireMenu>(builder =>
        {
            builder.Add(p => p.Anchor, "menu-anchor");
            builder.Add(p => p.Open, true);
            builder.Add(p => p.Items, new[] { item });
        });
 
        var pinButton = menuHost.WaitForElement("fluent-button[aria-label='Pin run']");
        var actionContainer = Assert.Single(menuHost.FindAll("span.aspire-menu-secondary-action-container[slot='end']"));
        Assert.NotNull(actionContainer.QuerySelector("fluent-button[aria-label='Pin run']"));
        Assert.Equal("false", pinButton.GetAttribute("aria-pressed"));
        var indicatorIcon = Assert.Single(menuHost.FindAll("span[slot='indicator'] svg"));
        Assert.Contains("fill: var(--colorBrandForeground1)", indicatorIcon.GetAttribute("style"), StringComparison.Ordinal);
        var secondaryActionIcon = Assert.Single(pinButton.QuerySelectorAll("svg"));
        Assert.Contains("fill: var(--colorBrandForeground1)", secondaryActionIcon.GetAttribute("style"), StringComparison.Ordinal);
 
        pinButton.Click();
 
        menuHost.WaitForAssertion(() =>
        {
            Assert.False(itemClicked);
            Assert.True(secondaryActionClicked);
            Assert.True(menuHost.Instance.Open);
            Assert.Single(menuHost.FindComponents<FluentMenu>());
            var unpinButton = menuHost.Find("fluent-button[aria-label='Unpin run']");
            Assert.Equal("true", unpinButton.GetAttribute("aria-pressed"));
        });
    }
 
    [Fact]
    public async Task UnanchoredAspireMenu_RendersFluentMenuAtCursor()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
 
        var menuHost = RenderComponent<AspireMenu>(builder =>
        {
            builder.Add(p => p.Anchor, "menu-anchor");
            builder.Add(p => p.Anchored, false);
            builder.Add(p => p.Items, new[] { new MenuButtonItem { Text = "Item" } });
        });
 
        var menu = Assert.Single(menuHost.FindComponents<FluentMenu>()).Instance;
        Assert.Equal("menu-anchor", menu.Trigger);
        Assert.Single(menuHost.FindComponents<FluentMenuList>());
        var cursorAnchor = menuHost.Find("#menu-anchor");
        Assert.Contains("position: fixed", cursorAnchor.GetAttribute("style"));
        Assert.Contains("anchor-name: --anchor-menu-anchor", cursorAnchor.GetAttribute("style"));
 
        await menuHost.InvokeAsync(() => menuHost.Instance.OpenAsync(123, 456));
 
        cursorAnchor = menuHost.Find("#menu-anchor");
        Assert.Contains("left: 123px", cursorAnchor.GetAttribute("style"));
        Assert.Contains("top: 456px", cursorAnchor.GetAttribute("style"));
    }
 
    [Fact]
    public async Task Header_LabelsMenuAndContributesToVerticalThreshold()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
 
        var header = new MenuButtonItem
        {
            Id = "resource-menu-header",
            IsHeader = true,
            Text = "Resource1",
            Icon = new Icons.Regular.Size16.Info()
        };
        var menuHost = RenderComponent<AspireMenu>(builder =>
        {
            builder.Add(p => p.Anchor, "menu-anchor");
            builder.Add(p => p.Anchored, false);
            builder.Add(p => p.Open, true);
            builder.Add(p => p.Items,
            [
                header,
                new MenuButtonItem { Text = "View details" }
            ]);
        });
 
        var menuElement = menuHost.Find("fluent-menu");
        Assert.Equal(header.Id, menuElement.GetAttribute("aria-labelledby"));
        Assert.Equal(header.Text, menuHost.Find($"#{header.Id}").TextContent.Trim());
 
        await menuHost.InvokeAsync(() => menuHost.Instance.OpenAsync(clientX: 10, clientY: 15));
    }
 
    [Fact]
    public void NestedAspireMenu_RendersItemsDirectlyInSubmenu()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
 
        var menuHost = RenderComponent<AspireMenu>(builder =>
        {
            builder.Add(p => p.Anchor, "menu-anchor");
            builder.Add(p => p.Items, new[]
            {
                new MenuButtonItem
                {
                    Text = "Commands",
                    NestedMenuItems =
                    [
                        new MenuButtonItem { Text = "Start" },
                        new MenuButtonItem { Text = "Stop" }
                    ]
                }
            });
        });
 
        Assert.Empty(menuHost.FindAll("fluent-menu-item > fluent-menu-list[slot='submenu'] > fluent-menu-list"));
        var nestedItems = menuHost.FindAll("fluent-menu-item > fluent-menu-list[slot='submenu'] > fluent-menu-item");
        Assert.Collection(
            nestedItems,
            item => Assert.Equal("Start", item.TextContent.Trim()),
            item => Assert.Equal("Stop", item.TextContent.Trim()));
    }
 
    [Fact]
    public async Task RemoveAspireMenu_RemovesFluentMenuFromHost()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
 
        var menuHost = RenderComponent<CascadingValue<bool>>(builder =>
        {
            builder.Add(p => p.Value, false);
            builder.AddChildContent<AspireMenu>(menuBuilder =>
            {
                menuBuilder.Add(p => p.Anchor, "menu-anchor");
                menuBuilder.Add(p => p.Items, new[] { new MenuButtonItem { Text = "Item" } });
            });
        });
        Assert.Single(menuHost.FindComponents<FluentMenu>());
 
        await menuHost.InvokeAsync(() => menuHost.FindComponent<AspireMenu>().Instance.OpenAsync(10, 10));
 
        menuHost.SetParametersAndRender(builder =>
        {
            builder.Add(p => p.Value, false);
            builder.Add(p => p.ChildContent, (RenderFragment)(_ => { }));
        });
 
        menuHost.WaitForAssertion(() => Assert.Empty(menuHost.FindComponents<FluentMenu>()));
    }
 
    [Fact]
    public async Task ClickItem_MenuButton_FocusesAnchorBeforeOnClick()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
        FluentUISetupHelpers.SetupFluentButton(this);
 
        var anchor = "view-options-button";
        var itemClicked = false;
        var focusElementInvocationHandler = JSInterop.SetupVoid("focusElement", anchor);
        focusElementInvocationHandler.SetVoidResult();
        var items = new List<MenuButtonItem>
        {
            new()
            {
                Text = "Show hidden resources",
                OnClick = () =>
                {
                    Assert.Single(focusElementInvocationHandler.Invocations);
                    itemClicked = true;
 
                    return Task.CompletedTask;
                }
            }
        };
 
        var menuButton = RenderComponent<AspireMenuButton>(builder =>
        {
            builder.Add(p => p.MenuButtonId, anchor);
            builder.Add(p => p.Title, "View options");
            builder.Add(p => p.ItemsProvider, () => items);
        });
 
        menuButton.Find($"#{anchor}").Click();
        var menuItem = menuButton.FindComponent<FluentMenuItem>();
        await menuButton.InvokeAsync(() => menuItem.Instance.OnClick.InvokeAsync(new MenuItemEventArgs()));
 
        Assert.True(itemClicked);
        var invocation = Assert.Single(focusElementInvocationHandler.Invocations);
        Assert.Collection(invocation.Arguments,
            argument => Assert.Equal(anchor, Assert.IsType<string>(argument)));
    }
 
    [Fact]
    public async Task ClickItem_MenuButtonWithFocusRestorationDisabled_DoesNotFocusAnchor()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
        FluentUISetupHelpers.SetupFluentButton(this);
 
        var anchor = "view-options-button";
        var itemClicked = false;
        var items = new List<MenuButtonItem>
        {
            new()
            {
                Text = "Show hidden resources",
                OnClick = () =>
                {
                    itemClicked = true;
                    return Task.CompletedTask;
                }
            }
        };
 
        var menuButton = RenderComponent<AspireMenuButton>(builder =>
        {
            builder.Add(p => p.MenuButtonId, anchor);
            builder.Add(p => p.Title, "View options");
            builder.Add(p => p.ItemsProvider, () => items);
            builder.Add(p => p.RestoreFocusOnItemClick, false);
        });
 
        menuButton.Find($"#{anchor}").Click();
        var menuItem = menuButton.FindComponent<FluentMenuItem>();
        await menuButton.InvokeAsync(() => menuItem.Instance.OnClick.InvokeAsync(new MenuItemEventArgs()));
 
        Assert.True(itemClicked);
        var focusElementInvocations = JSInterop.Invocations
            .Where(invocation => invocation.Identifier == "focusElement")
            .ToArray();
        Assert.Empty(focusElementInvocations);
    }
 
    [Fact]
    public void CheckableItems_RenderAccessibleRoleAndCheckedStateInDom()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
        FluentUISetupHelpers.SetupFluentButton(this);
 
        var anchor = "view-options-button";
        var items = new List<MenuButtonItem>
        {
            new() { Text = "Console", Role = MenuItemRole.Checkbox, Checked = false },
            new() { Text = "Terminal", Role = MenuItemRole.Checkbox, Checked = true },
        };
 
        var menuButton = RenderComponent<AspireMenuButton>(builder =>
        {
            builder.Add(p => p.MenuButtonId, anchor);
            builder.Add(p => p.Title, "View options");
            builder.Add(p => p.ItemsProvider, () => items);
        });
 
        menuButton.Find($"#{anchor}").Click();
        menuButton.WaitForElement("fluent-menu-item");
 
        var menuItems = menuButton.FindAll("fluent-menu-item");
        Assert.Equal(2, menuItems.Count);
 
        // Both options must carry the checkable role so assistive technology announces
        // them as a selectable set. Asserting on the rendered element (not the backing
        // MenuButtonItem) guards the Role passthrough through AspireMenu -> FluentMenuItem:
        // the unchecked item only gets role="menuitemcheckbox" from an explicit Role, since
        // FluentMenuItem otherwise infers that role solely from a checked item.
        Assert.Equal("menuitemcheckbox", menuItems[0].GetAttribute("role"));
        Assert.Equal("menuitemcheckbox", menuItems[1].GetAttribute("role"));
 
        // Only the active option reflects the checked state in the DOM. This guards the
        // Checked passthrough; without it the rendered items would lose their checked state.
        Assert.False(menuItems[0].HasAttribute("checked"));
        Assert.True(menuItems[1].HasAttribute("checked"));
    }
 
    [Fact]
    public async Task ChangeCheckableItem_InvokesItemCallbackAndClosesMenu()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
 
        var itemClicked = false;
        var item = new MenuButtonItem
        {
            Text = "Historical run",
            Role = MenuItemRole.Radio,
            OnClick = () =>
            {
                itemClicked = true;
                return Task.CompletedTask;
            }
        };
        var menuHost = RenderComponent<AspireMenu>(builder =>
        {
            builder.Add(p => p.Anchor, "menu-anchor");
            builder.Add(p => p.Open, true);
            builder.Add(p => p.Items, new[] { item });
        });
 
        var menuItem = menuHost.FindComponent<FluentMenuItem>();
        await menuHost.InvokeAsync(() => menuItem.Instance.CheckedChanged.InvokeAsync(true));
 
        Assert.True(itemClicked);
        Assert.False(menuHost.Instance.Open);
    }
 
    [Fact]
    public async Task ChangeRadioSelection_OnlyInvokesNewlyCheckedItemCallback()
    {
        FluentUISetupHelpers.AddCommonDashboardServices(this);
        FluentUISetupHelpers.SetupFluentUIComponents(this);
        FluentUISetupHelpers.SetupFluentMenu(this);
        FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
 
        var selectedItems = new List<string>();
        var currentItem = new MenuButtonItem
        {
            Text = "Live run",
            Role = MenuItemRole.Radio,
            Checked = true,
            OnClick = () =>
            {
                selectedItems.Add("current");
                return Task.CompletedTask;
            }
        };
        var historicalItem = new MenuButtonItem
        {
            Text = "Historical run",
            Role = MenuItemRole.Radio,
            OnClick = () =>
            {
                selectedItems.Add("historical");
                return Task.CompletedTask;
            }
        };
        var menuHost = RenderComponent<AspireMenu>(builder =>
        {
            builder.Add(p => p.Anchor, "menu-anchor");
            builder.Add(p => p.Open, true);
            builder.Add(p => p.Items, new[] { currentItem, historicalItem });
        });
 
        var menuItems = menuHost.FindComponents<FluentMenuItem>();
        await menuHost.InvokeAsync(() => menuItems[0].Instance.CheckedChanged.InvokeAsync(false));
        await menuHost.InvokeAsync(() => menuItems[0].Instance.OnClick.InvokeAsync(new MenuItemEventArgs()));
        await menuHost.InvokeAsync(() => menuItems[1].Instance.OnClick.InvokeAsync(new MenuItemEventArgs()));
        await menuHost.InvokeAsync(() => menuItems[1].Instance.CheckedChanged.InvokeAsync(true));
 
        Assert.Equal(["historical"], selectedItems);
        Assert.False(menuHost.Instance.Open);
    }
}