using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Media; using Avalonia.VisualTree; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.App.Views; using DodoSSH.Client.Session; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using NSubstitute; namespace DodoSSH.Client.App.Layout.Tests; /// /// How the v5b in-screen tab row answers a pointer, on both of the two ways it is used. /// /// /// The replacement for TerminalTabsTests, which measured the window-wide strip this control replaced /// — see SessionTabRow.axaml's own remark for why one control now serves both the terminal surface and /// the SFTP surface. The gestures that do not vary by surface — middle-click closes, "+" opens the palette, /// the tab pointer feedback — are carried over from that suite essentially unchanged; what is new here is /// and , which /// prove the one thing this control adds: the click and the accent colour are handed in rather than fixed. /// public sealed class SessionTabRowTests : IAsyncLifetime { private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private MainWindowViewModel shell = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"session-tabs-{Guid.CreateVersion7():N}"); workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); shell = new MainWindowViewModel( ClientPaths.Default, caches, workspace, new VaultKnownHostStore(), Substitute.For(), (_, _) => throw new NotSupportedException("nothing here signs in"), TimeProvider.System, Substitute.For()) { State = ShellState.Unlocked, }; return ValueTask.CompletedTask; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); await workspace.DisposeAsync(); caches.Dispose(); } [Fact] public async Task AMiddleClickOnATabClosesThatTab() { await OnTheStripAsync((strip, window) => { var doomed = shell.Tabs[0]; var survivor = shell.Tabs[1]; window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle); shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor); }); } [Fact] public async Task AMiddleClickOnTheStripBackgroundClosesNothing() { await OnTheStripAsync((strip, window) => { window.MouseDown(new Point(700, 17), MouseButton.Middle); shell.Tabs.Count.ShouldBe(2); }); } [Fact] public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing() { await OnTheStripAsync((strip, window) => { window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle); shell.Tabs.Count.ShouldBe(2); shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either"); }); } [Fact] public async Task AMiddleClickOnTheCrossClosesExactlyOneTab() { await OnTheStripAsync((strip, window) => { var survivor = shell.Tabs[1]; window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle); shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor); }); } [Fact] public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt() { await OnTheStripAsync((strip, window) => { var doomed = shell.Tabs[0]; var survivor = shell.Tabs[1]; shell.SelectTabCommand.Execute(survivor); var cross = CloseButton(strip, doomed); window.MouseDown(Centre(cross, window), MouseButton.Left); window.MouseUp(Centre(cross, window), MouseButton.Left); shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor); shell.SelectedTab.ShouldBe(survivor); }); } /// /// The terminal row's own click: TabCommand bound to SelectTabCommand, the same as the /// window-wide strip's used to be. /// [Fact] public async Task ALeftClickOnATabRunsTheBoundTabCommand() { await OnTheStripAsync((strip, window) => { var wanted = shell.Tabs[1]; shell.ShowScreenCommand.Execute(ShellScreen.Preferences); shell.IsTerminalShowing.ShouldBeFalse(); var button = TabButton(strip, wanted); window.MouseDown(Centre(button, window), MouseButton.Left); window.MouseUp(Centre(button, window), MouseButton.Left); shell.Tabs.Count.ShouldBe(2, "selecting is not closing"); shell.SelectedTab.ShouldBe(wanted); shell.IsTerminalShowing.ShouldBeTrue(); }); } /// /// The one thing this control adds over the strip it replaces: which command a tab click runs is handed /// in, not fixed. Proved with a command that is not SelectTabCommand at all, so a row that quietly /// ignored TabCommand and fell back to selecting the tab itself would fail this rather than pass /// it by coincidence. /// [Fact] public async Task TabRowTabClickRunsTheGivenCommand() { await LayoutHarness.OnTheUiThreadAsync( () => { shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22")); var seen = new List(); var strip = new SessionTabRow { DataContext = shell, TabCommand = new RelayCommand(tab => seen.Add(tab!)), }; var window = new Window { Content = strip }; LayoutHarness.Settle(window, 900, 600); try { var button = TabButton(strip, shell.Tabs[0]); window.MouseDown(Centre(button, window), MouseButton.Left); window.MouseUp(Centre(button, window), MouseButton.Left); seen.ShouldHaveSingleItem().ShouldBe(shell.Tabs[0]); shell.SelectedTab.ShouldBeNull("the given command ran instead of SelectTabCommand"); } finally { window.Close(); } }, Token); } [Fact] public async Task TheButtonThatOpensAConnectionOpensThePalette() { await OnTheStripAsync((strip, window) => { var plus = PlusButton(strip); window.MouseDown(Centre(plus, window), MouseButton.Left); window.MouseUp(Centre(plus, window), MouseButton.Left); shell.IsSearching.ShouldBeTrue(); }); } /// /// The strip's own height budget moved with it — see — /// but the invariant this test protects is the same one TerminalTabsTests protected: a row of /// tabs must not grow the chrome around it, however many are open. /// [Fact] public async Task TheRowIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs() { await LayoutHarness.OnTheUiThreadAsync( () => { for (var i = 0; i < 12; i++) { shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22")); } var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand }; var window = LayoutHarness.HostAtMinimumSize( strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight); try { strip.DesiredSize.Height.ShouldBe(LayoutHarness.SessionTabRowHeight); LayoutHarness.Unreachable(window).ShouldBeEmpty(); } finally { window.Close(); } }, Token); } /// /// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered — /// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads /// identically to the plus at rest by design. This proves the same two facts a different way: an inactive /// tab still changes under the pointer, and the plus never gets the treatment an active tab does /// — the coloured top border and the DeepChrome fill — which is the one visual claim "not a thing being /// chosen between" actually has to hold. /// [Fact] public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsAnActiveTab() { await OnTheStripAsync( (strip, window) => { var tab = TabButton(strip, shell.Tabs[0]); var resting = Fill(tab); window.MouseMove(Centre(tab, window)); LayoutHarness.Settle(window, 900, 600); tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it"); Fill(tab).ShouldNotBe( resting, "a tab that does not change under the pointer is one nobody can tell is clickable"); window.MouseMove(new Point(0, 0)); LayoutHarness.Settle(window, 900, 600); shell.SelectTabCommand.Execute(shell.Tabs[0]); LayoutHarness.Settle(window, 900, 600); var activeTab = TabButton(strip, shell.Tabs[0]); var plus = PlusButton(strip); Fill(plus).ShouldNotBe( Fill(activeTab), "the button that opens a connection never carries the active tab's DeepChrome fill"); Presenter(plus).BorderThickness.ShouldBe( default(Thickness), "it carries no outline, because it is not a thing being chosen between"); }); } /// /// The SFTP row's own accent: Classes="sftp" on this control's own usage — see /// MainWindow.axaml — is what selects Magenta over the terminal row's TerminalTabAccent /// for an active tab's top border. Read as a colour rather than a class list, so this catches the App.axaml /// selector actually resolving to a different brush rather than merely having the class applied. /// [Fact] public async Task SftpRowMarksTheColourItIsToldTo() { await LayoutHarness.OnTheUiThreadAsync( () => { shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22")); shell.SelectTabCommand.Execute(shell.Tabs[0]); var sshRow = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand }; var sftpRow = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand, Classes = { "sftp" }, }; var sshWindow = new Window { Content = sshRow }; var sftpWindow = new Window { Content = sftpRow }; LayoutHarness.Settle(sshWindow, 900, 600); LayoutHarness.Settle(sftpWindow, 900, 600); try { var sshBorder = Presenter(TabButton(sshRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush; var sftpBorder = Presenter(TabButton(sftpRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush; sshBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#7C5CFF"), "TerminalTabAccent"); sftpBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#DD1296"), "Magenta"); } finally { sshWindow.Close(); sftpWindow.Close(); } }, Token); } // ---- Helpers ---- private static ContentPresenter Presenter(Visual button) => button.GetVisualDescendants() .OfType() .First(presenter => presenter.Name is "PART_ContentPresenter"); private static Color? Fill(Visual button) => Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null; private Task OnTheStripAsync(Action body) => LayoutHarness.OnTheUiThreadAsync( () => { shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22")); shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22")); var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand }; var window = new Window { Content = strip }; LayoutHarness.Settle(window, 900, 600); try { body(strip, window); } finally { window.Close(); } }, Token); private static Button TabButton(Visual strip, TerminalTabViewModel tab) => strip.GetVisualDescendants() .OfType