using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Media; using Avalonia.VisualTree; 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 tab strip answers a pointer. /// /// /// /// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one /// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a /// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and /// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a /// binding. /// /// /// A UserControl in a bare window, for the reason the palette's suite is one: /// . No vault and no session — the strip /// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so /// they can be put there directly. Closing one asks the workspace to end a session it has never heard of, /// which the workspace answers by returning: that is the same path a real close takes, minus a shell. /// /// public sealed class TerminalTabsTests : 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($"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()) { // The only state the strip is ever interactive in. Assigned rather than reached through an // enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault. State = ShellState.Unlocked, }; return ValueTask.CompletedTask; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); await workspace.DisposeAsync(); caches.Dispose(); } /// /// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes /// one, and the strip answered nothing but a left click before. /// [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); }); } /// /// The other half of the rule, and the reason the handler is on the tab's own template root rather than /// on the strip: a middle click on the chrome between the last tab and the edge of the window must not /// close anything. Wiring it on the strip and testing what was underneath the pointer would have been /// the same feature with a way to get it wrong. /// [Fact] public async Task AMiddleClickOnTheStripBackgroundClosesNothing() { await OnTheStripAsync((strip, window) => { // Well right of two short tabs and the button after them, and inside the strip's own height. 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"); }); } /// /// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One /// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep. /// [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); }); } /// /// The one assumption the nested-button template makes, stated as a test. Avalonia's /// Button.OnPointerPressed takes the capture and marks a left press handled, so the cross does /// not also reach the tab underneath it — which would select a tab on its way out and leave the /// terminal switching to something that is about to disappear. /// [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); }); } [Fact] public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal() { 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(); }); } /// /// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing. /// See the note in TerminalTabs.axaml for why a flyout over the terminal's rectangle is not a /// claim this project is willing to make without a screenshot. /// [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 is the one row of chrome every screen pays for, so its height is part of the layout budget /// and this is what stops the budget drifting from the markup. See /// . /// [Fact] public async Task TheStripIsTheHeightTheBudgetAssumes_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 TerminalTabs { DataContext = shell }; var window = LayoutHarness.HostAtMinimumSize( strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight); try { // What it asks for, not what this host window gave it. Hosting it at 34 and then // asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is // exactly the regression the budget needs catching. strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight); LayoutHarness.Unreachable(window).ShouldBeEmpty(); } finally { window.Close(); } }, Token); } /// /// A tab lights under the pointer, and the button that opens one is not drawn as a tab. /// /// /// /// The only test in this suite that reads a brush rather than a rectangle, and it is here because that /// was the gap a real regression went through. Everything else measures heights and reachability, so a /// strip whose tabs had silently stopped answering the pointer passed all of it. /// /// /// What went wrong is worth stating, because the shape of it will recur. Avalonia has no specificity — /// the later declaration wins — and when the tab became a pill that paints its own background, that /// background was declared *after* the hover rule it relied on and after the exceptions the + /// is made of. So every tab lost its pointer feedback and the + gained a fill and an outline it /// is specifically not supposed to have. Both are one assertion each below. /// /// [Fact] public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsATab() { 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"); // Off the strip again, so the plus is measured at rest rather than under the pointer. window.MouseMove(new Point(0, 0)); LayoutHarness.Settle(window, 900, 600); var plus = PlusButton(strip); Fill(plus).ShouldNotBe( Fill(TabButton(strip, shell.Tabs[0])), "the button that opens a connection is not one of the connections"); Presenter(plus).BorderThickness.ShouldBe( default(Thickness), "it carries no outline, because it is not a thing being chosen between"); }); } // ---- Helpers ---- /// The presenter the Fluent theme actually paints, which is where every button style lands. private static ContentPresenter Presenter(Visual button) => button.GetVisualDescendants() .OfType() .First(presenter => presenter.Name is "PART_ContentPresenter"); /// /// The colour rather than the brush. Two instances holding the same /// colour are not equal, and it is the colour a user sees. /// private static Color? Fill(Visual button) => Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null; /// Two open tabs, laid out in a window the width the application's is. 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 TerminalTabs { DataContext = shell }; var window = new Window { Content = strip }; LayoutHarness.Settle(window, 900, 600); try { body(strip, window); } finally { window.Close(); } }, Token); /// /// Found by the class the style system already keys on, rather than by position in the visual tree: the /// template puts the cross inside the tab, so both buttons carry the same data context and only the /// classes tell them apart. /// private static Button TabButton(Visual strip, TerminalTabViewModel tab) => strip.GetVisualDescendants() .OfType