// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Xml.Linq;
using Aspire.Tray.Tests.Helpers;
namespace Aspire.Tray.Tests;
public class TrayPanelTests
{
private static readonly string[] s_sampleNames =
[
"typical", "empty", "connecting", "errors", "disconnected", "many",
"confirm-clear", "confirm-missing", "settings", "settings-unavailable", "actions", "actions-recent", "actions-open-in"
];
public static TheoryData<string> SampleNames => new(s_sampleNames);
private static readonly string[] s_applications = ["Visual Studio Code", "Terminal"];
private static TrayViewState Sample(string name) => name switch
{
"typical" => TrayPanelSamples.Typical(),
"empty" => TrayPanelSamples.Empty(),
"connecting" => TrayPanelSamples.Connecting(),
"errors" => TrayPanelSamples.Errors(),
"disconnected" => TrayPanelSamples.Disconnected(),
"many" => TrayPanelSamples.Many(),
_ => TrayPanelSamples.Typical()
};
/// <summary>Creates a panel for a sample, driving it into the sample's interactive state.</summary>
private static TrayPanel CreatePanel(string name, TrayPanelPalette palette, double maxHeight = TrayPanel.DefaultMaxHeight)
{
var state = Sample(name);
var panel = new TrayPanel(state, palette) { MaxHeight = maxHeight, OpenInApplications = s_applications };
switch (name)
{
case "actions":
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[3]).HitBounds);
break;
case "actions-open-in":
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
Click(panel, Target(panel, PanelTargetKind.OpenInMenu, state.AppHosts[0]).HitBounds);
break;
case "actions-recent":
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ToggleRecent).HitBounds);
Click(panel, Target(panel, PanelTargetKind.More, state.RecentAppHosts[0]).HitBounds);
break;
case "confirm-clear":
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).HitBounds);
break;
case "confirm-missing":
panel.ConfirmRemoveMissing(state.RecentAppHosts[1]);
break;
case "settings":
panel.ShowSettings(new(new TrayStartupState(true, true, null), null, Preview: false));
break;
case "settings-unavailable":
panel.ShowSettings(new(new TrayStartupState(false, false, null), null, Preview: false));
break;
}
return panel;
}
[Theory]
[MemberData(nameof(SampleNames))]
public void LayoutKeepsEveryTargetInsideThePanelAndFooterAtTheBottom(string name)
{
var panel = CreatePanel(name, TrayPanelPalette.Light, maxHeight: 600);
var scene = panel.Scene;
var bounds = new PanelRect(0, 0, scene.Width, scene.Height);
Assert.Equal(TrayPanel.Width, scene.Width);
Assert.InRange(scene.Height, TrayPanel.HeaderHeight + TrayPanel.FooterHeight, 600);
foreach (var target in scene.Targets)
{
Assert.True(bounds.Contains(target.HitBounds) || target.HitBounds.IsEmpty, $"{target.Key} at {target.HitBounds}");
Assert.True(target.HitBounds.IsEmpty || target.Bounds.Contains(target.HitBounds));
}
var footer = scene.Targets.Where(target => target.Kind is PanelTargetKind.Documentation or PanelTargetKind.Settings or PanelTargetKind.Quit).ToList();
Assert.Equal(3, footer.Count);
Assert.All(footer, target => Assert.InRange(target.Bounds.Y, scene.Height - TrayPanel.FooterHeight, scene.Height - target.Bounds.Height));
Assert.All(scene.Targets.Where(target => target.Kind is not (PanelTargetKind.Documentation or PanelTargetKind.Settings
or PanelTargetKind.Quit or PanelTargetKind.Back)),
target => Assert.True(target.HitBounds.IsEmpty || panel.Viewport.Contains(target.HitBounds)));
}
[Theory]
[MemberData(nameof(SampleNames))]
public void SiblingTargetsDoNotOverlapAndButtonsSitInsideTheirRow(string name)
{
var scene = CreatePanel(name, TrayPanelPalette.Dark).Scene;
var rows = scene.Targets.Where(target => target.Kind is PanelTargetKind.Row).ToList();
var buttons = scene.Targets.Where(target => target.Kind is PanelTargetKind.Dashboard or PanelTargetKind.Stop
or PanelTargetKind.Start or PanelTargetKind.More).ToList();
AssertNoOverlap(rows);
AssertNoOverlap(buttons);
foreach (var button in buttons)
{
var row = Assert.Single(rows, row => row.Key.Host == button.Key.Host && row.Key.Section == button.Key.Section);
Assert.True(row.Bounds.Contains(button.Bounds), $"{button.Key} outside its row");
}
}
[Theory]
[MemberData(nameof(SampleNames))]
public void RowTextNeverRunsUnderTheReservedButtons(string name)
{
var scene = CreatePanel(name, TrayPanelPalette.Light).Scene;
var buttons = scene.Targets.Where(target => target.Kind is PanelTargetKind.Dashboard or PanelTargetKind.Stop
or PanelTargetKind.Start or PanelTargetKind.More).Select(target => target.Bounds).ToList();
var texts = scene.Commands.OfType<TextCommand>().ToList();
Assert.All(texts, text => Assert.DoesNotContain(buttons, button => button.IntersectsWith(text.Bounds)));
}
[Theory]
[MemberData(nameof(SampleNames))]
public void ClipsAreBalanced(string name)
{
var commands = CreatePanel(name, TrayPanelPalette.Light).Scene.Commands;
Assert.Equal(commands.Count(command => command is PushClipCommand), commands.Count(command => command is PopClipCommand));
}
[Fact]
public void SectionsSplitRunningPinnedAndRecentHosts()
{
var scene = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light).Scene;
var rows = scene.Targets.Where(target => target.Kind == PanelTargetKind.Row).ToList();
Assert.Equal([PanelSection.Running, PanelSection.Running, PanelSection.Running, PanelSection.Pinned],
rows.Select(row => row.Key.Section));
Assert.Contains(scene.Commands.OfType<TextCommand>(), text => text.Text == "RECENT · 2");
Assert.Contains(scene.Targets, target => target.Kind == PanelTargetKind.ClearRecent);
}
[Fact]
public void RecentListIsCollapsedWhenAppHostsAreListedAndExpandsOnToggle()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Assert.False(panel.IsRecentExpanded);
var toggle = Assert.Single(panel.Scene.Targets, target => target.Kind == PanelTargetKind.ToggleRecent);
var response = Click(panel, toggle.HitBounds);
Assert.Equal(PanelActionKind.ToggleRecent, response.Action?.Kind);
Assert.True(panel.IsRecentExpanded);
Assert.Equal(2, panel.Scene.Targets.Count(target => target.Kind == PanelTargetKind.Row && target.Key.Section == PanelSection.Recent));
}
[Fact]
public void RecentListIsExpandedWhenNothingElseIsListed()
{
var panel = new TrayPanel(TrayPanelSamples.Empty(), TrayPanelPalette.Light);
Assert.True(panel.IsRecentExpanded);
Assert.Contains(panel.Scene.Commands.OfType<TextCommand>(), text => text.Text == "No AppHosts running");
Assert.Equal(2, panel.Scene.Targets.Count(target => target.Kind == PanelTargetKind.Row));
}
[Fact]
public void ClickingARowDoesNothingWhateverItsState()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
foreach (var host in state.AppHosts)
{
var response = Click(panel, Target(panel, PanelTargetKind.Row, host).HitBounds, offsetX: 10);
Assert.Null(response.Action);
Assert.Null(panel.ActionsRow);
}
var focused = Target(panel, PanelTargetKind.Row, state.AppHosts[0]).Key;
panel.Focus(focused);
Assert.Null(panel.Key(PanelKey.Enter).Action);
}
[Fact]
public void MoreOpensTheRowsActionListUnderIt()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { OpenInApplications = s_applications };
var host = state.AppHosts[2];
var row = Target(panel, PanelTargetKind.Row, host);
var response = Click(panel, Target(panel, PanelTargetKind.More, host).HitBounds);
Assert.Null(response.Action);
Assert.Equal((PanelSection.Running, host.Id), panel.ActionsRow);
var items = panel.Scene.Targets.Where(target => target.Host == host && target.Bounds.Y > row.Bounds.Bottom).ToList();
Assert.Equal(
[
"Open Redis in…",
"Show Redis in Finder",
"Copy the folder path of Redis",
"Pin Redis"
], items.Select(target => target.AccessibleName));
var next = Target(panel, PanelTargetKind.Row, state.AppHosts[3]);
Assert.All(items, item => Assert.True(item.Bounds.Bottom <= next.Bounds.Y, $"{item.Key} overlaps the next row"));
Assert.Equal(
["Open in", "Show in Finder", "Copy path", "Pin"],
panel.Scene.Commands.OfType<TextCommand>().Where(text => text.Bounds.Y >= items[0].Bounds.Y && text.Bounds.Bottom <= items[^1].Bounds.Bottom)
.Select(text => text.Text));
}
[Fact]
public void ActionListItemsReturnTheirActionsAndCloseTheList()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { OpenInApplications = s_applications, FileManagerName = "File Explorer" };
var host = state.AppHosts[0];
PanelAction? Pick(PanelTargetKind kind, int item = 0)
{
Click(panel, Target(panel, PanelTargetKind.More, host).HitBounds);
if (kind == PanelTargetKind.OpenIn)
{
Click(panel, Target(panel, PanelTargetKind.OpenInMenu, host).HitBounds);
}
var target = panel.Scene.Targets.Single(target => target.Key == new PanelTargetKey(kind, PanelSection.Running, host.Id, item));
var action = Click(panel, target.HitBounds).Action;
Assert.Null(panel.ActionsRow);
return action;
}
Assert.Equal(new PanelAction(PanelActionKind.OpenIn, host, default) { Application = "Terminal" },
Pick(PanelTargetKind.OpenIn, 1)! with { Anchor = default });
Assert.Equal(PanelActionKind.ShowInFolder, Pick(PanelTargetKind.ShowInFolder)?.Kind);
Assert.Equal(PanelActionKind.CopyPath, Pick(PanelTargetKind.CopyPath)?.Kind);
var pin = Pick(PanelTargetKind.TogglePin);
Assert.Equal(PanelActionKind.TogglePin, pin?.Kind);
Assert.Equal(host, pin?.Host);
Click(panel, Target(panel, PanelTargetKind.More, host).HitBounds);
Assert.Contains(panel.Scene.Commands.OfType<TextCommand>(), text => text.Text == "Show in File Explorer");
}
[Fact]
public void PinnedRowsOfferUnpinAndRecentRowsOfferRemove()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[3]).HitBounds);
Assert.Equal("Unpin Weather", panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.TogglePin).AccessibleName);
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind == PanelTargetKind.RemoveRecent);
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ToggleRecent).HitBounds);
var recent = state.RecentAppHosts[0];
Click(panel, Target(panel, PanelTargetKind.More, recent).HitBounds);
var remove = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.RemoveRecent);
var action = Click(panel, remove.HitBounds).Action;
Assert.Equal(new PanelAction(PanelActionKind.RemoveRecent, recent, remove.Bounds) { Recent = true }, action);
Assert.Null(panel.ActionsRow);
}
[Fact]
public void ClickingMoreAgainOrAnywhereElseClosesTheActionList()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
Assert.Equal("Hide actions for eShop", Target(panel, PanelTargetKind.More, state.AppHosts[0]).AccessibleName);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
Assert.Null(panel.ActionsRow);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[1]).HitBounds);
Assert.Equal((PanelSection.Running, state.AppHosts[1].Id), panel.ActionsRow);
var stop = Click(panel, Target(panel, PanelTargetKind.Stop, state.AppHosts[0]).HitBounds);
Assert.Equal(PanelActionKind.Stop, stop.Action?.Kind);
Assert.Null(panel.ActionsRow);
}
[Fact]
public void KeyboardMovesIntoTheActionListAndEscapeReturnsToMore()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { OpenInApplications = s_applications };
var host = state.AppHosts[0].Id;
var more = Target(panel, PanelTargetKind.More, state.AppHosts[0]).Key;
panel.Focus(more);
panel.Key(PanelKey.Enter);
Assert.Equal((PanelSection.Running, host), panel.ActionsRow);
panel.Key(PanelKey.Down);
Assert.Equal(new PanelTargetKey(PanelTargetKind.OpenInMenu, PanelSection.Running, host), panel.FocusedTarget);
panel.Key(PanelKey.Enter);
panel.Key(PanelKey.Down);
panel.Key(PanelKey.Down);
Assert.Equal(new PanelTargetKey(PanelTargetKind.OpenIn, PanelSection.Running, host, 1), panel.FocusedTarget);
// Escape steps out of the applications, then out of the list, then closes the panel.
Assert.Null(panel.Key(PanelKey.Escape).Action);
Assert.Equal(new PanelTargetKey(PanelTargetKind.OpenInMenu, PanelSection.Running, host), panel.FocusedTarget);
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind == PanelTargetKind.OpenIn);
panel.Key(PanelKey.Down);
Assert.Equal(new PanelTargetKey(PanelTargetKind.ShowInFolder, PanelSection.Running, host), panel.FocusedTarget);
Assert.Null(panel.Key(PanelKey.Escape).Action);
Assert.Null(panel.ActionsRow);
Assert.Equal(more, panel.FocusedTarget);
Assert.Equal(PanelActionKind.Close, panel.Key(PanelKey.Escape).Action?.Kind);
}
[Fact]
public void OpenInShowsTheApplicationsWithTheirIconsUnderIt()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { OpenInApplications = s_applications };
var host = state.AppHosts[0];
Click(panel, Target(panel, PanelTargetKind.More, host).HitBounds);
Assert.Empty(panel.Scene.Commands.OfType<AppIconCommand>());
var openIn = Target(panel, PanelTargetKind.OpenInMenu, host);
Assert.Null(Click(panel, openIn.HitBounds).Action);
Assert.Equal((PanelSection.Running, host.Id), panel.ActionsRow);
Assert.Equal($"Hide applications to open {host.DisplayName} in", Target(panel, PanelTargetKind.OpenInMenu, host).AccessibleName);
var applications = panel.Scene.Targets.Where(target => target.Kind == PanelTargetKind.OpenIn).ToList();
Assert.Equal(["Open eShop in Visual Studio Code", "Open eShop in Terminal"], applications.Select(target => target.AccessibleName));
var icons = panel.Scene.Commands.OfType<AppIconCommand>().ToList();
Assert.Equal(s_applications, icons.Select(icon => icon.Application));
Assert.All(icons, icon => Assert.NotEmpty(icon.Fallback));
// Nested: the icons start where the labels of the other items do, under "Open in".
Assert.All(icons, icon => Assert.True(icon.Bounds.X > openIn.Bounds.X + 30));
Assert.All(applications, target => Assert.True(target.Bounds.Y >= openIn.Bounds.Bottom));
Click(panel, Target(panel, PanelTargetKind.OpenInMenu, host).HitBounds);
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind == PanelTargetKind.OpenIn);
}
[Fact]
public void NoOpenInItemWithoutInstalledApplications()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind is PanelTargetKind.OpenInMenu or PanelTargetKind.OpenIn);
}
[Fact]
public void ActionListClosesWhenItsAppHostGoesAway()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
panel.Update(state with { AppHosts = state.AppHosts.Skip(1).ToArray() });
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind == PanelTargetKind.CopyPath);
Assert.Null(panel.ActionsRow);
}
[Fact]
public void ActionListBelowTheFoldIsScrolledIntoView()
{
var state = TrayPanelSamples.Many();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { MaxHeight = 400 };
var last = state.AppHosts[^1];
panel.Focus(Target(panel, PanelTargetKind.Row, last).Key);
panel.Invoke(Target(panel, PanelTargetKind.More, last).Key);
Assert.All(panel.Scene.Targets.Where(target => target.Kind is PanelTargetKind.CopyPath or PanelTargetKind.TogglePin),
target => Assert.True(panel.Viewport.Contains(target.Bounds), $"{target.Key} at {target.Bounds}"));
}
[Fact]
public void FocusFirstShowsKeyboardFocusOnTheFirstControl()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Assert.True(panel.FocusFirst().Redraw);
Assert.Equal(Target(panel, PanelTargetKind.Row, state.AppHosts[0]).Key, panel.FocusedTarget);
Assert.Contains(panel.Scene.Commands.OfType<StrokePathCommand>(), stroke => stroke.Color == TrayPanelPalette.Light.Focus);
}
[Fact]
public void RowButtonsReturnTheirActions()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Assert.Equal(PanelActionKind.Stop, Click(panel, Target(panel, PanelTargetKind.Stop, state.AppHosts[0]).HitBounds).Action?.Kind);
Assert.Equal(PanelActionKind.OpenDashboard, Click(panel, Target(panel, PanelTargetKind.Dashboard, state.AppHosts[0]).HitBounds).Action?.Kind);
var start = Click(panel, Target(panel, PanelTargetKind.Start, state.AppHosts[3]).HitBounds).Action;
Assert.Equal(PanelActionKind.Start, start?.Kind);
Assert.Equal(state.AppHosts[3].Id.AppHostPath, start?.Host?.Id.AppHostPath);
Assert.Equal(PanelActionKind.Settings, Click(panel, panel.Scene.Targets.Single(t => t.Kind == PanelTargetKind.Settings).HitBounds).Action?.Kind);
Assert.Equal(PanelActionKind.Quit, Click(panel, panel.Scene.Targets.Single(t => t.Kind == PanelTargetKind.Quit).HitBounds).Action?.Kind);
Assert.Equal(PanelActionKind.Documentation, Click(panel, panel.Scene.Targets.Single(t => t.Kind == PanelTargetKind.Documentation).HitBounds).Action?.Kind);
}
[Fact]
public void DisabledButtonsDoNothing()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var dashboard = Target(panel, PanelTargetKind.Dashboard, state.AppHosts[2]);
Assert.False(dashboard.Enabled);
Assert.Null(Click(panel, dashboard.HitBounds).Action);
}
[Fact]
public void ReleasingOutsideThePressedTargetCancelsTheClick()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var stop = Target(panel, PanelTargetKind.Stop, state.AppHosts[0]);
var more = Target(panel, PanelTargetKind.More, state.AppHosts[0]);
panel.PointerDown(stop.HitBounds.CenterX, stop.HitBounds.CenterY);
var response = panel.PointerUp(more.HitBounds.CenterX, more.HitBounds.CenterY);
Assert.Null(response.Action);
}
[Fact]
public void StateUpdatesAreDeferredWhileAPressIsInProgress()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var stop = Target(panel, PanelTargetKind.Stop, state.AppHosts[0]);
panel.PointerDown(stop.HitBounds.CenterX, stop.HitBounds.CenterY);
Assert.False(panel.Update(TrayPanelSamples.Empty()));
Assert.Same(state, panel.State);
var response = panel.PointerUp(stop.HitBounds.CenterX, stop.HitBounds.CenterY);
Assert.Equal(PanelActionKind.Stop, response.Action?.Kind);
Assert.Equal(state.AppHosts[0], response.Action?.Host);
Assert.Empty(panel.State.AppHosts);
}
[Fact]
public void RowButtonsAreDrawnOnlyWhileTheRowIsHovered()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var before = panel.Scene.Commands.Count;
var row = Target(panel, PanelTargetKind.Row, state.AppHosts[0]);
Assert.True(panel.PointerMoved(row.HitBounds.X + 10, row.HitBounds.CenterY).Redraw);
var hovered = panel.Scene.Commands.Count;
Assert.False(panel.PointerMoved(row.HitBounds.X + 11, row.HitBounds.CenterY).Redraw);
Assert.True(panel.PointerLeft().Redraw);
// One hover fill plus the dashboard (two strokes), stop (one fill) and more (three fills) icons.
Assert.Equal(before + 7, hovered);
Assert.Equal(before, panel.Scene.Commands.Count);
}
[Fact]
public void ManyRowsScrollInsideAFixedHeaderAndFooter()
{
var panel = new TrayPanel(TrayPanelSamples.Many(), TrayPanelPalette.Light) { MaxHeight = 480 };
var scene = panel.Scene;
var footer = scene.Targets.Single(target => target.Kind == PanelTargetKind.Quit).Bounds;
Assert.Equal(480, scene.Height);
Assert.Equal(0, panel.ScrollOffset);
Assert.True(panel.Scroll(-100).Redraw);
Assert.Equal(100, panel.ScrollOffset);
Assert.Equal(footer, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.Quit).Bounds);
Assert.True(panel.Scroll(-100_000).Redraw);
var max = panel.ScrollOffset;
Assert.False(panel.Scroll(-1).Redraw);
Assert.Equal(max, panel.ScrollOffset);
var last = panel.Scene.Targets.Last(target => target.Kind == PanelTargetKind.ToggleRecent);
Assert.InRange(last.Bounds.Bottom, panel.Viewport.Y, panel.Viewport.Bottom);
Assert.True(panel.Scroll(100_000).Redraw);
Assert.Equal(0, panel.ScrollOffset);
}
[Fact]
public void RowsScrolledOutOfViewCannotBeClicked()
{
var state = TrayPanelSamples.Many();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { MaxHeight = 400 };
panel.Scroll(-200);
var first = panel.Scene.Targets.FirstOrDefault(target => target.Kind == PanelTargetKind.Row && target.Host == state.AppHosts[0]);
Assert.NotNull(first);
Assert.True(first.HitBounds.IsEmpty);
Assert.Null(panel.HitTest(100, TrayPanel.HeaderHeight - 1));
}
[Fact]
public void KeyboardNavigationMovesAcrossRowsAndScrollsTheFocusIntoView()
{
var state = TrayPanelSamples.Many();
var panel = new TrayPanel(state, TrayPanelPalette.Light) { MaxHeight = 400 };
panel.Key(PanelKey.Down);
Assert.Equal(new PanelTargetKey(PanelTargetKind.Row, PanelSection.Running, state.AppHosts[0].Id), panel.FocusedTarget);
for (var i = 1; i < state.AppHosts.Count; i++)
{
panel.Key(PanelKey.Down);
}
Assert.Equal(new PanelTargetKey(PanelTargetKind.Row, PanelSection.Running, state.AppHosts[^1].Id), panel.FocusedTarget);
var focused = panel.Scene.Targets.Single(target => target.Key == panel.FocusedTarget);
Assert.True(panel.Viewport.Contains(focused.Bounds));
Assert.True(panel.ScrollOffset > 0);
Assert.Contains(panel.Scene.Commands.OfType<StrokePathCommand>(), stroke => stroke.Color == TrayPanelPalette.Light.Focus);
}
[Fact]
public void TabVisitsInlineButtonsAndSkipsDisabledOnes()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var visited = new List<PanelTargetKey>();
var enabled = panel.Scene.Targets.Count(target => target.Enabled);
for (var i = 0; i < enabled; i++)
{
panel.Key(PanelKey.Tab);
visited.Add(panel.FocusedTarget!.Value);
}
Assert.Equal(panel.Scene.Targets.Where(target => target.Enabled).Select(target => target.Key), visited);
Assert.DoesNotContain(new PanelTargetKey(PanelTargetKind.Dashboard, PanelSection.Running, state.AppHosts[2].Id), visited);
panel.Key(PanelKey.Tab);
Assert.Equal(visited[0], panel.FocusedTarget);
panel.Key(PanelKey.ShiftTab);
Assert.Equal(visited[^1], panel.FocusedTarget);
}
[Fact]
public void EscapeClosesThePanel()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Assert.Equal(PanelActionKind.Close, panel.Key(PanelKey.Escape).Action?.Kind);
}
[Fact]
public void StopIsSentAtOnceWithoutAConfirmation()
{
// Like the dashboard, stopping an AppHost doesn't ask first.
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var action = Click(panel, Target(panel, PanelTargetKind.Stop, state.AppHosts[1]).HitBounds).Action;
Assert.Equal(new PanelAction(PanelActionKind.Stop, state.AppHosts[1], Target(panel, PanelTargetKind.Stop, state.AppHosts[1]).Bounds), action);
Assert.Null(panel.Confirmation);
Assert.Contains(panel.Scene.Targets, target => target.Kind == PanelTargetKind.Row && target.Host == state.AppHosts[1]);
}
[Fact]
public void ClickingAStartingOrStoppingRowDoesNothing()
{
var state = TrayPanelSamples.Errors();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var stopping = state.AppHosts.Single(host => host.IsStopping);
var starting = state.AppHosts.Single(host => host.IsStarting);
Assert.False(Target(panel, PanelTargetKind.Dashboard, stopping).Enabled);
Assert.Null(Click(panel, Target(panel, PanelTargetKind.Row, stopping).HitBounds, offsetX: 10).Action);
Assert.Null(Click(panel, Target(panel, PanelTargetKind.Row, starting).HitBounds, offsetX: 10).Action);
}
[Fact]
public void EscapeCancelsAConfirmationBeforeClosingThePanel()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).HitBounds);
var first = panel.Key(PanelKey.Escape);
Assert.True(first.Redraw);
Assert.Null(first.Action);
Assert.Null(panel.Confirmation);
Assert.Equal(PanelActionKind.Close, panel.Key(PanelKey.Escape).Action?.Kind);
}
[Fact]
public void CancelButtonDismissesTheConfirmation()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).HitBounds);
var response = Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ConfirmCancel).HitBounds);
Assert.Null(response.Action);
Assert.Null(panel.Confirmation);
Assert.Contains(panel.Scene.Targets, target => target.Kind == PanelTargetKind.ToggleRecent);
}
[Fact]
public void ActingOnAnotherTargetAbandonsTheConfirmation()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).HitBounds);
var action = Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.Documentation).HitBounds).Action;
Assert.Equal(PanelActionKind.Documentation, action?.Kind);
Assert.Null(panel.Confirmation);
}
[Fact]
public void ConfirmationIsDroppedWhenItsAppHostGoesAway()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
panel.ConfirmRemoveMissing(state.RecentAppHosts[1]);
panel.Update(state with { RecentAppHosts = state.RecentAppHosts.Take(1).ToArray() });
_ = panel.Scene;
Assert.Null(panel.Confirmation);
}
[Fact]
public void ClearRecentAsksInlineAndReturnsClearRecentWhenConfirmed()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
var response = Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).HitBounds);
Assert.Null(response.Action);
Assert.Equal(PanelConfirmKind.ClearRecent, panel.Confirmation);
var texts = panel.Scene.Commands.OfType<TextCommand>().Select(text => text.Text).ToList();
Assert.Contains("Clear recent AppHosts?", texts);
Assert.Contains("Pinned AppHosts are kept.", texts);
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind is PanelTargetKind.ToggleRecent);
Assert.Equal(new PanelTargetKey(PanelTargetKind.ConfirmCancel, PanelSection.Recent, null), panel.FocusedTarget);
var accept = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ConfirmAccept);
Assert.Equal(PanelActionKind.ClearRecent, Click(panel, accept.HitBounds).Action?.Kind);
Assert.Null(panel.Confirmation);
}
[Fact]
public void MissingRecentAppHostIsOfferedForRemovalInline()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var missing = state.RecentAppHosts[1];
panel.ConfirmRemoveMissing(missing);
Assert.True(panel.IsRecentExpanded);
Assert.Equal(PanelConfirmKind.RemoveMissing, panel.Confirmation);
Assert.Contains(panel.Scene.Commands.OfType<TextCommand>(), text => text.Text == missing.Id.AppHostPath);
var action = Click(panel, Target(panel, PanelTargetKind.ConfirmAccept, missing).HitBounds).Action;
Assert.Equal(PanelActionKind.RemoveRecent, action?.Kind);
Assert.Equal(missing, action?.Host);
Assert.True(action?.Recent);
}
[Fact]
public void SettingsViewShowsTogglesAndReturnsTheirNewValues()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Assert.Equal(PanelActionKind.Settings, Click(panel, panel.Scene.Targets.Single(t => t.Kind == PanelTargetKind.Settings).HitBounds).Action?.Kind);
Assert.False(panel.IsSettingsOpen);
panel.ShowSettings(new(new TrayStartupState(false, true, null), null, Preview: false));
Assert.True(panel.IsSettingsOpen);
Assert.DoesNotContain(panel.Scene.Targets, target => target.Kind == PanelTargetKind.Row);
Assert.Equal(new PanelTargetKey(PanelTargetKind.Back, PanelSection.None, null), panel.FocusedTarget);
var launch = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.LaunchAtSignIn);
Assert.Equal(TraySettingsText.PanelStartupOption, launch.AccessibleName);
Assert.Equal(PanelAccessibleRole.Switch, launch.Role);
Assert.False(launch.Checked);
var setLaunch = Click(panel, launch.HitBounds).Action;
Assert.Equal(PanelActionKind.SetLaunchAtSignIn, setLaunch?.Kind);
Assert.True(setLaunch?.Enabled);
var texts = panel.Scene.Commands.OfType<TextCommand>().Select(text => text.Text).ToList();
Assert.Contains(TraySettingsText.GetAboutDescription(preview: false), texts);
Assert.Contains(texts, text => text.StartsWith("Version: ", StringComparison.Ordinal));
panel.UpdateSettings(new(new TrayStartupState(true, true, null), null, Preview: false));
Assert.True(panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.LaunchAtSignIn).Checked);
}
[Fact]
public void LaunchAtSignInCannotBeTurnedOnWithoutAStableInstallation()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
panel.ShowSettings(new(new TrayStartupState(false, false, null), null, Preview: false));
var launch = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.LaunchAtSignIn);
Assert.False(launch.Enabled);
Assert.Null(Click(panel, launch.HitBounds).Action);
Assert.Contains(panel.Scene.Commands.OfType<TextCommand>(), text => text.Text == TraySettingsText.StableNativeInstallationRequired);
}
[Fact]
public void SettingsErrorsAreShownUnderTheToggle()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
panel.ShowSettings(new(null, "Could not read launch at sign-in: denied", Preview: false));
var launch = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.LaunchAtSignIn);
Assert.False(launch.Enabled);
Assert.Contains(panel.Scene.Commands.OfType<TextCommand>(),
text => text.Text == "Could not read launch at sign-in: denied" && text.Color == TrayPanelPalette.Light.Danger);
}
[Fact]
public void BackAndEscapeLeaveSettingsAndRestoreTheList()
{
var panel = new TrayPanel(TrayPanelSamples.Many(), TrayPanelPalette.Light) { MaxHeight = 400 };
panel.Scroll(-120);
var scroll = panel.ScrollOffset;
panel.ShowSettings(new(new TrayStartupState(false, true, null), null, Preview: false));
Assert.Equal(0, panel.ScrollOffset);
var escape = panel.Key(PanelKey.Escape);
Assert.Null(escape.Action);
Assert.False(panel.IsSettingsOpen);
Assert.Equal(scroll, panel.ScrollOffset);
Assert.Equal(new PanelTargetKey(PanelTargetKind.Settings, PanelSection.None, null), panel.FocusedTarget);
panel.ShowSettings(new(new TrayStartupState(false, true, null), null, Preview: false));
Assert.Null(Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.Back).HitBounds).Action);
Assert.False(panel.IsSettingsOpen);
}
[Fact]
public void ResetLeavesConfirmationsAndSettings()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
Click(panel, Target(panel, PanelTargetKind.Stop, state.AppHosts[0]).HitBounds);
panel.Reset();
Assert.Null(panel.Confirmation);
panel.ShowSettings(new(null, null, Preview: false));
panel.Reset();
Assert.False(panel.IsSettingsOpen);
Click(panel, Target(panel, PanelTargetKind.More, state.AppHosts[0]).HitBounds);
panel.Reset();
Assert.Null(panel.ActionsRow);
}
[Fact]
public void ToggleGlyphStaysInsideItsBox()
{
foreach (var on in new[] { true, false })
{
var commands = new List<PanelCommand>();
var box = new PanelRect(10, 20, 32, 18);
TrayPanelGlyphs.AddToggle(commands, on, enabled: true, box, TrayPanelPalette.Light);
Assert.All(commands, command => Assert.True(box.Inset(-0.5, -0.5).Contains(GetBounds(command)), $"{command}"));
}
}
[Fact]
public void FocusOnARemovedRowIsForgotten()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
panel.Key(PanelKey.Down);
Assert.NotNull(panel.FocusedTarget);
panel.Update(TrayPanelSamples.Connecting());
_ = panel.Scene;
Assert.Null(panel.FocusedTarget);
}
[Fact]
public void ResetClearsInteractionState()
{
var panel = new TrayPanel(TrayPanelSamples.Many(), TrayPanelPalette.Light) { MaxHeight = 400 };
panel.Key(PanelKey.End);
panel.Scroll(-50);
panel.Reset();
Assert.Null(panel.FocusedTarget);
Assert.Equal(0, panel.ScrollOffset);
}
[Theory]
[InlineData((int)DiscoveryState.Connecting, "Connecting…")]
[InlineData((int)DiscoveryState.Disconnected, "Reconnecting…")]
[InlineData((int)DiscoveryState.Incompatible, "Incompatible CLI")]
[InlineData((int)DiscoveryState.LimitExceeded, "Discovery limit exceeded")]
public void HeaderStatusReportsDiscoveryProblems(int discovery, string expected)
{
var (text, _) = TrayPanel.GetHeaderStatus(new((DiscoveryState)discovery, [], ""), TrayPanelPalette.Light);
Assert.Equal(expected, text);
}
[Fact]
public void HeaderStatusSummarizesRunningAndAttention()
{
Assert.Equal("3 running · 1 needs attention", TrayPanel.GetHeaderStatus(TrayPanelSamples.Typical(), TrayPanelPalette.Light).Text);
Assert.Equal("No AppHosts running", TrayPanel.GetHeaderStatus(TrayPanelSamples.Empty(), TrayPanelPalette.Light).Text);
Assert.Equal("3 running · 1 starting · 2 need attention",
TrayPanel.GetHeaderStatus(TrayPanelSamples.Errors(), TrayPanelPalette.Light).Text);
}
[Fact]
public void RowDetailShowsStatusLocationAndPidOnlyForDuplicateNames()
{
var host = TrayPanelSamples.Running("/Users/dev/src/eShop/src/eShop.AppHost/eShop.AppHost.csproj", 48213, AppHostHealth.Warning);
// The location uses the platform's separator: "src/eShop.AppHost", or "src\eShop.AppHost" on Windows.
var location = Path.Combine("src", "eShop.AppHost");
Assert.Equal(($"Needs attention · {location}", false), TrayPanel.GetRowDetail(host, live: true, showPid: false));
Assert.Equal(($"Needs attention · {location} · PID 48213", false), TrayPanel.GetRowDetail(host, live: true, showPid: true));
Assert.Equal(($"Status unavailable · {location}", false), TrayPanel.GetRowDetail(host, live: false, showPid: false));
Assert.Equal(("Boom.", true), TrayPanel.GetRowDetail(host with { Error = "Boom." }, live: true, showPid: false));
var scene = new TrayPanel(TrayPanelSamples.Errors(), TrayPanelPalette.Light).Scene;
Assert.Contains(scene.Commands.OfType<TextCommand>(), text => text.Text == $"Unhealthy · {location} · PID 48213");
}
[Theory]
[InlineData(true, false, false, (int)AppHostHealth.Healthy, (int)PanelStatusGlyph.Healthy)]
[InlineData(true, false, false, (int)AppHostHealth.Warning, (int)PanelStatusGlyph.Warning)]
[InlineData(true, false, false, (int)AppHostHealth.Unhealthy, (int)PanelStatusGlyph.Unhealthy)]
[InlineData(true, false, false, (int)AppHostHealth.Unknown, (int)PanelStatusGlyph.Unknown)]
[InlineData(false, false, false, (int)AppHostHealth.Healthy, (int)PanelStatusGlyph.Unknown)]
[InlineData(true, true, false, (int)AppHostHealth.Healthy, (int)PanelStatusGlyph.Busy)]
[InlineData(true, false, true, (int)AppHostHealth.Healthy, (int)PanelStatusGlyph.Warning)]
public void StatusGlyphFollowsHealthAndActivity(bool live, bool stopping, bool error, int health, int expected)
{
var host = TrayPanelSamples.Running("/src/A/A.AppHost.csproj", 1, (AppHostHealth)health, stopping: stopping, error: error ? "Failed." : null);
Assert.Equal((PanelStatusGlyph)expected, TrayPanel.GetStatusGlyph(host, live));
}
[Fact]
public void OfflineRowsUseStoppedOrBusyGlyphs()
{
Assert.Equal(PanelStatusGlyph.Stopped, TrayPanel.GetStatusGlyph(TrayPanelSamples.Offline("/src/A/A.cs"), live: true));
Assert.Equal(PanelStatusGlyph.Busy, TrayPanel.GetStatusGlyph(TrayPanelSamples.Offline("/src/A/A.cs", starting: true), live: true));
Assert.Equal(PanelStatusGlyph.Warning, TrayPanel.GetStatusGlyph(TrayPanelSamples.Offline("/src/A/A.cs", error: "No."), live: true));
}
[Theory]
[MemberData(nameof(SampleNames))]
public void HeaderIsAOneLineBarMatchingTheFooter(string name)
{
foreach (var palette in new[] { TrayPanelPalette.Light, TrayPanelPalette.Dark,
TrayPanelPalette.FromSystemColors(new(0, 0, 0), new(255, 255, 255), new(26, 235, 255), new(63, 242, 63)) })
{
var panel = CreatePanel(name, palette);
var scene = panel.Scene;
var gutters = scene.Commands.OfType<FillPathCommand>().Where(fill => fill.Color == palette.Gutter)
.Select(fill => fill.Path.GetControlBounds()).ToList();
Assert.Equal(TrayPanel.FooterHeight, TrayPanel.HeaderHeight);
// Rounded corners come from arc math, so compare with a tolerance.
static bool Near(PanelRect a, PanelRect b)
=> Math.Abs(a.X - b.X) < 1e-9 && Math.Abs(a.Y - b.Y) < 1e-9 && Math.Abs(a.Right - b.Right) < 1e-9 && Math.Abs(a.Bottom - b.Bottom) < 1e-9;
Assert.Contains(gutters, bounds => Near(bounds, new(0, 0, TrayPanel.Width, TrayPanel.HeaderHeight)));
Assert.Contains(gutters, bounds => Near(bounds, new(0, scene.Height - TrayPanel.FooterHeight, TrayPanel.Width, TrayPanel.FooterHeight)));
var separators = scene.Commands.OfType<FillPathCommand>().Where(fill => fill.Color == palette.Separator)
.Select(fill => fill.Path.GetControlBounds()).ToList();
Assert.Contains(new PanelRect(0, TrayPanel.HeaderHeight - 1, TrayPanel.Width, 1), separators);
Assert.Contains(new PanelRect(0, scene.Height - TrayPanel.FooterHeight, TrayPanel.Width, 1), separators);
// Everything in the header is centered on one line, like the footer's buttons.
var middle = TrayPanel.HeaderHeight / 2;
var header = scene.Commands.OfType<TextCommand>().Where(text => text.Bounds.Bottom <= TrayPanel.HeaderHeight).ToList();
Assert.NotEmpty(header);
Assert.All(header, text => Assert.Equal(middle, text.Bounds.CenterY));
Assert.All(scene.Commands.OfType<LogoCommand>(), logo => Assert.Equal(new PanelRect(20, middle - 10, 20, 20), logo.Bounds));
Assert.All(scene.Targets.Where(target => target.Kind == PanelTargetKind.Back),
back => Assert.Equal(middle, back.Bounds.CenterY));
Assert.All(scene.Targets.TakeLast(3), button => Assert.Equal(scene.Height - TrayPanel.FooterHeight / 2, button.Bounds.CenterY));
}
}
[Fact]
public void HeaderReadsTitleThenStatusOnOneLine()
{
var state = TrayPanelSamples.Typical();
var scene = new TrayPanel(state, TrayPanelPalette.Light).Scene;
var title = scene.Accessibility.Single(element => element.Key.Label == "header.title");
var status = scene.Accessibility.Single(element => element.Key.Label == "header.status");
Assert.Equal("Aspire", title.Name);
Assert.Equal(TrayPanel.GetHeaderStatus(state, TrayPanelPalette.Light).Text, status.Name);
Assert.Equal(title.Bounds.CenterY, status.Bounds.CenterY);
Assert.True(title.Bounds.Right < status.Bounds.X);
}
[Fact]
public void NoticeShowsABannerAndPushesTheListDown()
{
var withNotice = new TrayPanel(TrayPanelSamples.Errors(), TrayPanelPalette.Light);
var without = new TrayPanel(TrayPanelSamples.Errors() with { Notice = null }, TrayPanelPalette.Light);
Assert.Contains(withNotice.Scene.Commands.OfType<TextCommand>(), text => text.Text.StartsWith("Unable to save", StringComparison.Ordinal));
Assert.True(withNotice.Viewport.Y > without.Viewport.Y);
Assert.Equal(TrayPanel.HeaderHeight, without.Viewport.Y);
}
[Theory]
[InlineData(0, 90)]
[InlineData(-60, 300)]
[InlineData(45, -200)]
[InlineData(0, 360)]
public void ArcsUseSegmentsOfAtMostNinetyDegreesThatStayOnTheCircle(double start, double sweep)
{
var path = new PanelPath().Arc(10, 20, 5, start, sweep);
var cubics = path.Segments.Where(segment => segment.Verb == PanelPathVerb.CubicTo).ToList();
Assert.Equal((int)Math.Ceiling(Math.Abs(sweep) / 90), cubics.Count);
foreach (var cubic in cubics)
{
Assert.Equal(5, Math.Sqrt(Math.Pow(cubic.X3 - 10, 2) + Math.Pow(cubic.Y3 - 20, 2)), 6);
// The curve midpoint of a 4/3 tan(φ/4) approximation is within 0.03% of the radius.
var mid = Midpoint(path.Segments[path.Segments.ToList().IndexOf(cubic) - 1], cubic);
Assert.Equal(5, Math.Sqrt(Math.Pow(mid.X - 10, 2) + Math.Pow(mid.Y - 20, 2)), 2);
}
var end = (start + sweep) * Math.PI / 180;
Assert.Equal(10 + 5 * Math.Cos(end), cubics[^1].X3, 6);
Assert.Equal(20 + 5 * Math.Sin(end), cubics[^1].Y3, 6);
}
[Fact]
public void QuarterArcControlPointsMatchTheStandardKappa()
{
var cubic = new PanelPath().Arc(0, 0, 1, 0, 90).Segments[1];
Assert.Equal(PanelPath.QuarterArcKappa, cubic.Y1, 6);
Assert.Equal(PanelPath.QuarterArcKappa, cubic.X2, 6);
}
[Fact]
public void RoundedRectStaysInsideItsBounds()
{
var rect = new PanelRect(8, 10, 100, 40);
var path = PanelPath.RoundedRect(rect, 6);
Assert.Equal(rect, path.GetControlBounds());
Assert.Equal(PanelPathVerb.Close, path.Segments[^1].Verb);
}
[Theory]
[InlineData((int)PanelStatusGlyph.Healthy)]
[InlineData((int)PanelStatusGlyph.Warning)]
[InlineData((int)PanelStatusGlyph.Unhealthy)]
[InlineData((int)PanelStatusGlyph.Unknown)]
[InlineData((int)PanelStatusGlyph.Stopped)]
[InlineData((int)PanelStatusGlyph.Busy)]
public void StatusGlyphsStayInsideTheirBox(int value)
{
var glyph = (PanelStatusGlyph)value;
var commands = new List<PanelCommand>();
var box = new PanelRect(40, 60, 20, 20);
TrayPanelGlyphs.AddStatus(commands, glyph, box, TrayPanelPalette.Light);
Assert.NotEmpty(commands);
Assert.All(commands, command => Assert.True(box.Contains(GetBounds(command)), $"{glyph}: {GetBounds(command)}"));
}
[Theory]
[InlineData((int)PanelIcon.ExternalLink)]
[InlineData((int)PanelIcon.Stop)]
[InlineData((int)PanelIcon.Play)]
[InlineData((int)PanelIcon.More)]
[InlineData((int)PanelIcon.Gear)]
[InlineData((int)PanelIcon.Power)]
[InlineData((int)PanelIcon.Globe)]
[InlineData((int)PanelIcon.ChevronDown)]
[InlineData((int)PanelIcon.ChevronRight)]
[InlineData((int)PanelIcon.Alert)]
[InlineData((int)PanelIcon.Layers)]
[InlineData((int)PanelIcon.AppWindow)]
[InlineData((int)PanelIcon.Folder)]
[InlineData((int)PanelIcon.Copy)]
[InlineData((int)PanelIcon.Pin)]
[InlineData((int)PanelIcon.Unpin)]
[InlineData((int)PanelIcon.Remove)]
public void IconsStayInsideTheirBox(int value)
{
var icon = (PanelIcon)value;
var commands = new List<PanelCommand>();
var box = new PanelRect(4, 8, 16, 16);
TrayPanelGlyphs.AddIcon(commands, icon, box, TrayPanelPalette.Light.TextPrimary);
Assert.NotEmpty(commands);
Assert.All(commands, command => Assert.True(box.Contains(GetBounds(command)), $"{icon}: {GetBounds(command)}"));
}
[Fact]
public void PalettesParseTheSpecifiedColors()
{
Assert.Equal(new PanelColor(0xF9, 0xF9, 0xFB), TrayPanelPalette.Light.Background);
Assert.Equal(new PanelColor(0x6A, 0x4B, 0xD6), TrayPanelPalette.Light.Accent);
Assert.Equal(new PanelColor(0, 0, 0, 20), TrayPanelPalette.Light.Separator);
Assert.Equal(new PanelColor(0x25, 0x25, 0x29), TrayPanelPalette.Dark.Background);
Assert.Equal(new PanelColor(255, 255, 255, 18), TrayPanelPalette.Dark.Hover);
Assert.Equal("#11223344", PanelColor.FromHex("#11223344").ToHex());
Assert.Equal(new PanelColor(128, 128, 128), new PanelColor(255, 255, 255, 128).Over(new(0, 0, 0)));
}
[Theory]
[InlineData("light")]
[InlineData("dark")]
public void ConfirmationButtonLabelsKeepReadableContrast(string name)
{
var palette = name == "dark" ? TrayPanelPalette.Dark : TrayPanelPalette.Light;
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, palette);
Click(panel, panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).HitBounds);
var accept = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ConfirmAccept);
var label = Assert.Single(panel.Scene.Commands.OfType<TextCommand>(), text => text.Bounds == accept.Bounds);
var fill = panel.Scene.Commands.OfType<FillPathCommand>().Last(fill => fill.Path.GetControlBounds().Contains(accept.Bounds.CenterX, accept.Bounds.CenterY)
&& fill.Path.GetControlBounds().Width <= accept.Bounds.Width + 1);
// 12 px labels need 4.5:1 (WCAG AA), also against the darkened hover and pressed fills.
Assert.Equal(palette.DangerFill, fill.Color);
Assert.True(TrayPanelPalette.ContrastRatio(label.Color, fill.Color) >= 4.5, $"{name}: {TrayPanelPalette.ContrastRatio(label.Color, fill.Color):F2}:1");
Assert.True(TrayPanelPalette.ContrastRatio(palette.OnColor, PanelColor.FromRgba(0, 0, 0, 0.22).Over(palette.DangerFill)) >= 4.5);
}
[Theory]
[InlineData("light")]
[InlineData("dark")]
public void SmallTextKeepsContrastOnEveryListBackground(string name)
{
// Section labels (TextTertiary), subtitles (TextSecondary) and error lines (Danger) are
// 11 and 12 px, so they need 4.5:1 on the panel background and on the hover and pressed
// tints drawn over it; the unhealthy disc is filled so its white cross keeps 4.5:1 too.
var palette = name == "dark" ? TrayPanelPalette.Dark : TrayPanelPalette.Light;
var backgrounds = new[] { palette.Background, palette.Hover.Over(palette.Background), palette.Pressed.Over(palette.Background) };
foreach (var text in new[] { palette.TextSecondary, palette.TextTertiary, palette.Danger })
{
Assert.All(backgrounds, background => Assert.True(TrayPanelPalette.ContrastRatio(text, background) >= 4.5,
$"{name}: {text.ToHex()} on {background.ToHex()} is {TrayPanelPalette.ContrastRatio(text, background):F2}:1"));
}
Assert.True(TrayPanelPalette.ContrastRatio(palette.OnColor, palette.DangerFill) >= 4.5);
var commands = new List<PanelCommand>();
TrayPanelGlyphs.AddStatus(commands, PanelStatusGlyph.Unhealthy, new(0, 0, 20, 20), palette);
Assert.Equal(palette.DangerFill, commands.OfType<FillPathCommand>().First().Color);
}
public static TheoryData<string> ContrastPaletteNames => new(
"light", "dark", "light-contrast", "dark-contrast", "hc-black", "hc-white", "hc-aquatic", "hc-desert", "hc-low-gray");
/// <summary>
/// Palettes to check status marks in. The high-contrast ones use the Windows 11 contrast
/// themes' window, window text, highlight and gray text colors, plus a theme whose gray text
/// is too close to its window color for the window color alone to work as a mark.
/// </summary>
private static TrayPanelPalette ContrastPalette(string name) => name switch
{
"light" => TrayPanelPalette.Light,
"dark" => TrayPanelPalette.Dark,
"light-contrast" => TrayPanelPalette.Light.WithIncreasedContrast(),
"dark-contrast" => TrayPanelPalette.Dark.WithIncreasedContrast(),
"hc-black" => TrayPanelPalette.FromSystemColors(new(0, 0, 0), new(255, 255, 255), new(26, 235, 255), new(63, 242, 63)),
"hc-white" => TrayPanelPalette.FromSystemColors(new(255, 255, 255), new(0, 0, 0), new(55, 0, 110), new(96, 0, 0)),
"hc-aquatic" => TrayPanelPalette.FromSystemColors(new(32, 32, 32), new(255, 255, 255), new(142, 227, 240), new(167, 167, 167)),
"hc-desert" => TrayPanelPalette.FromSystemColors(new(255, 250, 239), new(61, 61, 61), new(144, 57, 4), new(103, 103, 103)),
_ => TrayPanelPalette.FromSystemColors(new(0, 0, 0), new(255, 255, 255), new(26, 235, 255), new(40, 40, 40))
};
[Theory]
[MemberData(nameof(ContrastPaletteNames))]
public void StatusMarksKeepThreeToOneAgainstTheirDisc(string name)
{
// The check, "!", cross and "?" are what tell the states apart without color, so each
// needs 3:1 against its disc (WCAG 1.4.11, non-text contrast).
var palette = ContrastPalette(name);
foreach (var (glyph, disc, mark) in new[]
{
(PanelStatusGlyph.Healthy, palette.Success, palette.SuccessMark),
(PanelStatusGlyph.Warning, palette.Warning, palette.WarningMark),
(PanelStatusGlyph.Unhealthy, palette.DangerFill, palette.DangerMark),
(PanelStatusGlyph.Unknown, palette.Neutral, palette.NeutralMark)
})
{
var ratio = TrayPanelPalette.ContrastRatio(mark, disc);
Assert.True(ratio >= TrayPanelPalette.MinimumMarkContrast, $"{name} {glyph}: {mark.ToHex()} on {disc.ToHex()} is {ratio:F2}:1");
// The glyph draws exactly those colors: the disc first, then only the mark.
var commands = new List<PanelCommand>();
TrayPanelGlyphs.AddStatus(commands, glyph, new(0, 0, 20, 20), palette);
Assert.Equal(disc, Assert.IsType<FillPathCommand>(commands[0]).Color);
Assert.All(commands.Skip(1), command => Assert.Equal(mark, command switch
{
FillPathCommand fill => fill.Color,
StrokePathCommand stroke => stroke.Color,
_ => throw new InvalidOperationException()
}));
}
}
[Fact]
public void StatusMarksStayWhiteWhereWhitePassesAndDarkenOnlyWhereItFails()
{
var white = PanelColor.FromHex("#FFFFFF");
var dark = PanelColor.FromHex("#1C1C1E");
Assert.Equal([white, dark, white, white], Marks(TrayPanelPalette.Light));
Assert.Equal([dark, dark, white, white], Marks(TrayPanelPalette.Dark));
// The switch knob and the danger button keep their white label color.
Assert.Equal(white, TrayPanelPalette.Light.OnColor);
Assert.Equal(white, TrayPanelPalette.Dark.OnColor);
static PanelColor[] Marks(TrayPanelPalette palette)
=> [palette.SuccessMark, palette.WarningMark, palette.DangerMark, palette.NeutralMark];
}
[Fact]
public void HighContrastPaletteUsesSystemColors()
{
var palette = TrayPanelPalette.FromSystemColors(new(0, 0, 0), new(255, 255, 255), new(26, 235, 255), new(63, 242, 63));
var scene = new TrayPanel(TrayPanelSamples.Errors(), palette).Scene;
Assert.True(palette.IsHighContrast);
Assert.Equal(new PanelColor(255, 255, 255), palette.TextSecondary);
Assert.All(scene.Commands.OfType<TextCommand>().Where(text => text.Color != palette.Accent),
text => Assert.Equal(new PanelColor(255, 255, 255), text.Color));
}
[Theory]
[MemberData(nameof(SampleNames))]
public void SvgRendererProducesWellFormedDocuments(string name)
{
var svg = TrayPanelSvgRenderer.Render(CreatePanel(name, TrayPanelPalette.Dark).Scene);
var document = XDocument.Parse(svg);
Assert.Equal("svg", document.Root!.Name.LocalName);
Assert.Contains(document.Descendants(), element => element.Name.LocalName == "text");
}
/// <summary>
/// Writes preview images of each state to ASPIRE_TRAY_PANEL_PREVIEW_DIR, or to the test output
/// directory, for design review and documentation.
/// </summary>
[Fact]
public void WritePreviewImages()
{
var directory = Environment.GetEnvironmentVariable("ASPIRE_TRAY_PANEL_PREVIEW_DIR") is { Length: > 0 } configured
? configured
: Path.Combine(AppContext.BaseDirectory, "TrayPanelPreviews");
Directory.CreateDirectory(directory);
var logo = File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Assets", "aspire.png"));
var written = new List<string>();
foreach (var palette in new[] { TrayPanelPalette.Light, TrayPanelPalette.Dark })
{
foreach (var name in s_sampleNames)
{
var panel = CreatePanel(name, palette, maxHeight: 520);
switch (name)
{
case "typical":
// Show the hover affordance on the second row.
var row = panel.Scene.Targets.Where(target => target.Kind == PanelTargetKind.Row).ElementAt(1);
panel.PointerMoved(row.Bounds.X + 40, row.Bounds.CenterY);
break;
case "many":
panel.Scroll(-140);
panel.PointerMoved(200, panel.Viewport.Y + 140);
break;
}
var path = Path.Combine(directory, $"{name}-{palette.Name}.svg");
File.WriteAllText(path, TrayPanelSvgRenderer.Render(panel.Scene, logo, margin: 24));
written.Add(path);
}
}
Assert.All(written, path => Assert.True(new FileInfo(path).Length > 0));
}
[Theory]
[MemberData(nameof(SampleNames))]
public void AccessibilityListsEveryTargetOnceInReadingOrder(string name)
{
var scene = CreatePanel(name, TrayPanelPalette.Light).Scene;
Assert.Equal(scene.Targets, scene.Accessibility.Where(element => element.Target is not null).Select(element => element.Target));
Assert.Equal(scene.Accessibility.Count, scene.Accessibility.Select(element => element.Key).Distinct().Count());
Assert.Equal([PanelTargetKind.Settings, PanelTargetKind.Documentation, PanelTargetKind.Quit],
scene.Accessibility.TakeLast(3).Select(element => element.Target!.Kind));
Assert.All(scene.Accessibility, element => Assert.False(string.IsNullOrWhiteSpace(element.Name)));
}
[Fact]
public void AccessibilityReadsTheHeaderAndSectionTitlesAsText()
{
var state = TrayPanelSamples.Typical();
var scene = new TrayPanel(state, TrayPanelPalette.Light).Scene;
var (status, _) = TrayPanel.GetHeaderStatus(state, TrayPanelPalette.Light);
Assert.Equal(
[("Aspire", PanelAccessibleRole.Text), (status, PanelAccessibleRole.Text), ("Running", PanelAccessibleRole.Text)],
scene.Accessibility.Take(3).Select(element => (element.Name, element.Role)));
var firstRow = scene.Accessibility[3];
Assert.Equal(PanelTargetKind.Row, firstRow.Target?.Kind);
Assert.Equal(state.AppHosts[0], firstRow.Target?.Host);
Assert.Equal(PanelAccessibleRole.Button, firstRow.Role);
var recent = scene.Accessibility.Single(element => element.Target?.Kind == PanelTargetKind.ToggleRecent);
Assert.Equal("Show 2 recent AppHosts", recent.Name);
}
[Fact]
public void ConfirmationStripReadsItsQuestionBeforeItsControls()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
panel.Invoke(panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).Key);
var strip = panel.Scene.Accessibility.SkipWhile(element => element.Key.Label != "confirm.question").Take(4).ToList();
Assert.Equal(
[
("Clear recent AppHosts?", PanelAccessibleRole.Text, (bool?)null),
("Pinned AppHosts are kept.", PanelAccessibleRole.Text, null),
("Cancel", PanelAccessibleRole.Button, null),
("Clear recent AppHosts", PanelAccessibleRole.Button, null)
],
strip.Select(element => (element.Name, element.Role, element.Checked)));
}
[Fact]
public void InvokeActivatesLikeAClickAndMovesFocus()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
Assert.Null(panel.Invoke(panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ClearRecent).Key).Action);
Assert.Equal(PanelConfirmKind.ClearRecent, panel.Confirmation);
Assert.Equal(PanelTargetKind.ConfirmCancel, panel.FocusedTarget?.Kind);
var accept = panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.ConfirmAccept).Key;
var clear = panel.Invoke(accept).Action;
Assert.Equal(PanelActionKind.ClearRecent, clear?.Kind);
Assert.Null(panel.Confirmation);
}
[Fact]
public void InvokeIgnoresDisabledAndMissingTargets()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var disabled = Target(panel, PanelTargetKind.Dashboard, state.AppHosts[2]);
Assert.False(disabled.Enabled);
Assert.Equal(PanelResponse.None, panel.Invoke(disabled.Key));
Assert.Equal(PanelResponse.None, panel.Invoke(new(PanelTargetKind.Back, PanelSection.None, null)));
Assert.Null(panel.FocusedTarget);
}
[Fact]
public void FocusScrollsAnOffscreenElementIntoView()
{
var panel = new TrayPanel(TrayPanelSamples.Many(), TrayPanelPalette.Light) { MaxHeight = 400 };
var last = panel.Scene.Accessibility.Last(element => element.Target?.Kind == PanelTargetKind.Row);
Assert.True(last.IsOffscreen);
var status = panel.Scene.Accessibility.Single(element => element.Key.Label == "header.status");
Assert.Equal(status.Bounds, status.VisibleBounds);
Assert.True(panel.Focus(last.Target!.Key).Redraw);
Assert.Equal(last.Target.Key, panel.FocusedTarget);
Assert.True(panel.ScrollOffset > 0);
var visible = panel.Scene.Accessibility.Single(element => element.Key == last.Key);
Assert.False(visible.IsOffscreen);
Assert.Equal(visible.Bounds, visible.VisibleBounds);
Assert.True(panel.Scene.Accessibility.Single(element => element.Key.Label == "section.running").IsOffscreen);
}
[Fact]
public void SettingsSwitchesCarryTheirDescription()
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light);
panel.ShowSettings(new(new TrayStartupState(false, true, null), null, Preview: false));
var launch = panel.Scene.Accessibility.Single(element => element.Target?.Kind == PanelTargetKind.LaunchAtSignIn);
Assert.Equal(PanelAccessibleRole.Switch, launch.Role);
Assert.Equal(TraySettingsText.PanelStartupOption, launch.Name);
Assert.Equal(TraySettingsText.StartupDescription, launch.Description);
Assert.False(launch.Checked);
Assert.Equal(["Back to AppHosts", TraySettingsText.PanelTitle, "General"],
panel.Scene.Accessibility.Take(3).Select(element => element.Name));
}
[Fact]
public void IconButtonsHaveTooltipsAndLabeledControlsDoNot()
{
var state = TrayPanelSamples.Typical();
var panel = new TrayPanel(state, TrayPanelPalette.Light);
var running = state.AppHosts[0];
var stopped = state.AppHosts[3];
Assert.Equal("Open dashboard", Target(panel, PanelTargetKind.Dashboard, running).Tooltip);
Assert.Equal("Stop", Target(panel, PanelTargetKind.Stop, running).Tooltip);
Assert.Equal("Start", Target(panel, PanelTargetKind.Start, stopped).Tooltip);
Assert.Equal("More actions", Target(panel, PanelTargetKind.More, running).Tooltip);
Assert.Equal(["Settings", "Documentation", "Quit the tray. AppHosts keep running."],
panel.Scene.Targets.TakeLast(3).Select(target => target.Tooltip));
Assert.All(panel.Scene.Targets.Where(target => target.Kind is PanelTargetKind.Row or PanelTargetKind.ToggleRecent or PanelTargetKind.ClearRecent),
target => Assert.Null(target.Tooltip));
Click(panel, Target(panel, PanelTargetKind.More, running).HitBounds);
Assert.Equal("Hide actions", Target(panel, PanelTargetKind.More, running).Tooltip);
panel.ShowSettings(new(null, null, Preview: false));
Assert.Equal("Back", panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.Back).Tooltip);
Assert.Equal("Close settings", panel.Scene.Targets.Single(target => target.Kind == PanelTargetKind.Settings).Tooltip);
}
[Theory]
[InlineData("Quit")]
[InlineData("Exit")]
public void QuitSaysItClosesOnlyTheTray(string verb)
{
var panel = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light) { QuitVerb = verb };
var quit = panel.Scene.Accessibility.Single(element => element.Target?.Kind == PanelTargetKind.Quit);
Assert.Equal(verb, quit.Name);
Assert.Equal($"{verb} the tray. AppHosts keep running.", quit.Description);
Assert.Equal($"{verb} the tray. AppHosts keep running.", quit.Target!.Tooltip);
Assert.Equal(PanelActionKind.Quit, panel.Invoke(quit.Target.Key).Action?.Kind);
}
[Fact]
public void FooterButtonsAreIconsOnTheRightInSettingsDocumentationQuitOrder()
{
var scene = new TrayPanel(TrayPanelSamples.Typical(), TrayPanelPalette.Light).Scene;
var footer = scene.Targets.TakeLast(3).ToList();
Assert.Equal([PanelTargetKind.Settings, PanelTargetKind.Documentation, PanelTargetKind.Quit], footer.Select(target => target.Kind));
Assert.All(footer, target => Assert.Equal(TrayPanel.ButtonSize, target.Bounds.Width));
Assert.True(footer[0].Bounds.Right <= footer[1].Bounds.X && footer[1].Bounds.Right <= footer[2].Bounds.X);
Assert.DoesNotContain(scene.Commands.OfType<TextCommand>(), text => text.Text == "Documentation");
}
private static PanelHitTarget Target(TrayPanel panel, PanelTargetKind kind, AppHostMenuItem host)
=> panel.Scene.Targets.Single(target => target.Kind == kind && target.Host == host);
private static PanelResponse Click(TrayPanel panel, PanelRect bounds, double? offsetX = null)
{
var x = offsetX is { } dx ? bounds.X + dx : bounds.CenterX;
panel.PointerMoved(x, bounds.CenterY);
panel.PointerDown(x, bounds.CenterY);
return panel.PointerUp(x, bounds.CenterY);
}
private static void AssertNoOverlap(List<PanelHitTarget> targets)
{
for (var i = 0; i < targets.Count; i++)
{
for (var j = i + 1; j < targets.Count; j++)
{
Assert.False(targets[i].Bounds.IntersectsWith(targets[j].Bounds), $"{targets[i].Key} overlaps {targets[j].Key}");
}
}
}
private static PanelRect GetBounds(PanelCommand command)
{
var (path, width) = command switch
{
FillPathCommand fill => (fill.Path, 0.0),
StrokePathCommand stroke => (stroke.Path, stroke.Width),
_ => throw new InvalidOperationException()
};
// Control points bound the curve; a stroke extends half its width beyond the path.
return path.GetControlBounds().Inset(-width / 2, -width / 2);
}
private static (double X, double Y) Midpoint(PanelPathSegment previous, PanelPathSegment cubic)
=> (0.125 * previous.X3 + 0.375 * cubic.X1 + 0.375 * cubic.X2 + 0.125 * cubic.X3,
0.125 * previous.Y3 + 0.375 * cubic.Y1 + 0.375 * cubic.Y2 + 0.125 * cubic.Y3);
}