using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.VisualTree; using DodoSSH.Client.App.ViewModels; using DodoSSH.Client.App.Views; using DodoSSH.Client.Session; 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); } // ---- Helpers ---- /// 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