using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; 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 three fixed tabs select what they name, and Vaults comes back to the page it was left on. /// /// /// /// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with /// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent: /// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on /// Hosts, which is most of the time. /// /// /// Driven through the strip rather than through the commands, because what is being checked is that /// three buttons in the markup are wired to three different things. Three commands called directly /// would pass on a strip whose SFTP tab was bound to the S3 one. /// /// [Fact] public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage() { await OnTheStripAsync((strip, window) => { shell.ShowScreenCommand.Execute(ShellScreen.Snippets); shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab"); Click(FixedTab(strip, "SFTP"), window); shell.IsTransfersShowing.ShouldBeTrue(); shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time"); Click(FixedTab(strip, "S3"), window); shell.IsBucketsShowing.ShouldBeTrue(); shell.IsTransfersShowing.ShouldBeFalse(); Click(FixedTab(strip, "Vaults"), window); shell.IsVaultsTab.ShouldBeTrue(); shell.Screen.ShouldBe( ShellScreen.Snippets, "the Vaults tab comes back to the page it was left on, not to Hosts"); }); } /// /// The caret is the second half of the Vaults pill and the only control in this strip that opens a /// popup. Asserted as behaviour rather than as markup, because what makes it correct is the order in /// the handler rather than the flyout being attached — see the test below. /// [Fact] public async Task TheCaretBesideVaults_OpensTheVaultMenu() { await OnTheStripAsync((strip, window) => { var caret = CaretButton(strip); FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeFalse("nothing has been pressed yet"); Click(caret, window); FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeTrue(); }); } /// /// Opening the vault menu selects the Vaults tab first, so the renderer is collapsed under it. /// /// /// /// The occlusion guard, and the reason this strip may have a flyout at all. The comment on the /// + button refuses one because a popup dropping into the terminal's rectangle would have to /// composite above a native child window, which this project does not claim without a screenshot. The /// caret sidesteps the question rather than answering it: it goes to the Vaults tab before it opens, /// and a page surface is one where the renderer is not drawn. /// /// /// So the assertion is on rather than on anything /// about the popup. A change that opened the flyout without moving the surface first would still show a /// menu in every screenshot anybody took on a machine where it happened to work. /// /// [Fact] public async Task OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt() { await OnTheStripAsync((strip, window) => { shell.SelectTabCommand.Execute(shell.Tabs[0]); shell.IsTerminalShowing.ShouldBeTrue("this test is meaningless without one in the way"); Click(CaretButton(strip), window); shell.IsVaultsTab.ShouldBeTrue(); shell.IsTerminalShowing.ShouldBeFalse( "the flyout must never have to composite over the renderer's native child window"); }); } /// /// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a /// destination from a machine in this strip, and a fixed tab that grew one would be offering to close /// SFTP. /// /// The Vaults caret is a sibling of its tab rather than a child, which is what keeps this assertion /// meaning what it says: a button inside a fixed tab would still be a close box. /// /// [Fact] public async Task TheFixedTabsCarryNoCloseBox() { await OnTheStripAsync((strip, _) => { foreach (var label in new[] { "Vaults", "SFTP", "S3" }) { FixedTab(strip, label) .GetVisualDescendants() .OfType