using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.VisualTree; using DodoSSH.Client.App.Views; using DodoSSH.Client.Session; using DodoSSH.Client.Session.Tests; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; using NSubstitute; namespace DodoSSH.Client.App.Layout.Tests; /// /// How the grid of host cards answers a pointer. /// /// /// /// This was HostSidebarTests, and it moved with the list: the cards are on /// now, and so is every handler that was wired to them. See /// HostsScreen.axaml.cs. /// /// /// Separate from , which measures these controls rather than driving them. /// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by /// measuring: a right click has to move the selection before the menu opens, because all three of /// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host /// happened to be selected would delete the wrong machine, which is the version of this mistake worth a /// suite. /// /// /// A real over a real unlocked vault, for the reason the other suites here use /// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own /// hosts and groups. /// /// public sealed class HostGridTests : IAsyncLifetime { private const string Passphrase = "a sufficiently long passphrase"; private const string ServerUrl = "https://dodossh.example"; /// Far below the shipped profile: nothing here attacks a wrap. private static readonly Argon2Profile CheapProfile = Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); private readonly FakeAccountServer server = new(); private readonly StubKeyBinding keyBinding = new(); private readonly VaultKnownHostStore knownHosts = new(); private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private VaultSession session = null!; private VaultViewModel vault = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public async ValueTask InitializeAsync() { caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}"); await caches.MigrateAsync(Token); await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile) .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token); outcome.IsUnlocked.ShouldBeTrue(outcome.Message); session = outcome.Session!; workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); await knownHosts.OpenAsync(session, Token); vault = new VaultViewModel(session, workspace, knownHosts, static () => null); await SeedAsync(); } /// public async ValueTask DisposeAsync() { knownHosts.Close(); await workspace.DisposeAsync(); await vault.DisposeAsync(); caches.Dispose(); } /// /// The rule the menu depends on. Without it the three commands would act on whatever was selected /// before, which for Delete is a question asked about one machine and answered about another. /// [Fact] public async Task ARightClickSelectsTheHostUnderThePointer() { await OnTheGridAsync((screen, window) => { var first = Row(vault, "prod-db"); var other = Row(vault, "stage-web"); vault.SelectedHost = first; RightClick(CardFor(screen, other), window); vault.SelectedHost.ShouldBeSameAs(other); var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull(); menu.IsOpen.ShouldBeTrue(); // The commands are the vault's, which is the other half of putting the menu on the list rather // than in the item template: a menu inside the template would have the row for its data context, // and every one of these would silently bind to nothing. var edit = menu.Items.OfType().Single(item => item.Header is "Edit…"); edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand); edit.Command!.Execute(null); vault.IsEditing.ShouldBeTrue(); vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before"); }); } /// /// A heading is an item in the same list and the control will happily select it, but it is not a host — /// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing /// or act on a machine somewhere else in the grid. /// [Fact] public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing() { await OnTheGridAsync((screen, window) => { var selected = Row(vault, "prod-db"); vault.SelectedHost = selected; var heading = screen.HostGrid .GetVisualDescendants() .OfType() .First(item => item.DataContext is SidebarGroupHeader); RightClick(heading, window); vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on"); screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse(); }); } /// /// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back. /// Driven through the property the card's ListBox binds rather than through a click, because /// what is worth holding is the rule — the filter is a property of the grid, and it also moves the /// selection the group's own EDIT and DELETE act on. A click would test Avalonia's SelectedItem /// binding, which is not this application's code. /// [Fact] public async Task ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt() { var production = vault.Groups.Single(); vault.MoveHostToGroupCommand.Execute( new HostGroupMove(Row(vault, "prod-db"), production.EntityId)); vault.GroupFilter = production; vault.VisibleHosts.Select(row => row.Label) .ShouldBe(["prod-db"], "only what is filed under the chosen group"); vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on"); vault.IsFilteredByGroup.ShouldBeTrue(); vault.ClearGroupFilterCommand.Execute(null); vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group"); vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off"); } // ---- Helpers ---- private static void RightClick(Visual row, Visual window) { var at = Centre(row, window); ((Window)window).MouseDown(at, MouseButton.Right); ((Window)window).MouseUp(at, MouseButton.Right); } private Task OnTheGridAsync(Action body) => LayoutHarness.OnTheUiThreadAsync( () => { var screen = new HostsScreen { DataContext = vault }; var window = LayoutHarness.HostAtMinimumSize( screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight); try { body(screen, window); } finally { window.Close(); } }, Token); private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) => screen.GetVisualDescendants() .OfType() .First(item => ReferenceEquals(item.DataContext, host)); private static HostRowViewModel Row(VaultViewModel vault, string label) => vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal)); private static Point Centre(Visual control, Visual window) => control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window) ?? throw new InvalidOperationException("the control is not in this window's tree"); /// Two hosts and a group, so there is a heading in the list and a selection to move off. private async Task SeedAsync() { foreach (var label in new[] { "prod-db", "stage-web" }) { vault.NewHostCommand.Execute(null); vault.EditorLabel = label; vault.EditorHostname = $"{label}.internal"; vault.EditorUsername = "deploy"; await vault.SaveHostCommand.ExecuteAsync(null); } vault.GroupEditorLabel = "production"; await vault.SaveGroupCommand.ExecuteAsync(null); await vault.LoadAsync(Token); } }