using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Threading; 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"); }); } /// /// /// The space around the cards is part of the same ListBox, and a menu offering Connect, Edit and /// Delete over it would be three entries acting on whichever machine happened to be selected — which is /// the whole mistake this handler exists to prevent, reached by clicking nothing at all. /// /// /// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is /// the event's source, and a source that is the list rather than an item is exactly what the empty space /// produces; a coordinate would additionally be asserting where the wrap put the cards. /// /// [Fact] public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing() { await OnTheGridAsync((screen, _) => { var selected = Row(vault, "prod-db"); vault.SelectedHost = selected; screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs { RoutedEvent = Control.ContextRequestedEvent, Source = screen.HostGrid, }); vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on"); screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse(); }); } /// /// A host held over a group card would be filed there, and one held over another host card would not. /// /// /// /// The group headings that used to sit between the cards are gone — see /// ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings — and with them went the thing a /// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card /// dropped onto another card used to file it beside that one, which was legible while a heading said /// which group the band of cards belonged to and is guesswork now that none does. /// /// /// What is not here is the platform's half — picking the card up, the cursor, the drop itself. /// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the /// gesture is ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected, and what neither /// covers is in docs/manual-checks.md 7.6. /// /// [Fact] public async Task TheGroupCardsAreWhatAcceptsADroppedHost() { await OnTheGridAsync((screen, _) => { var carried = new DataTransfer(); carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db"))); var onto = screen.GroupGrid .GetVisualDescendants() .OfType() .Single(item => item.DataContext is HostGroupRowViewModel); var over = Over(onto, carried); onto.Classes.ShouldContain("droptarget", "the card says it would take the host"); over.DragEffects.ShouldBe(DragDropEffects.Move); var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried); refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform"); refused.DragEffects.ShouldBe( DragDropEffects.None, "a card dropped onto another card would be filed somewhere nothing on screen names"); // And the group card it was over a moment ago stops offering to take it, which is the half of // this that is wrong far more often than the mark appearing at all. onto.Classes.ShouldNotContain("droptarget"); }); } /// /// /// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it /// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it. /// While a single press meant both, a group could not be named without every host outside it leaving the /// screen at the same moment. /// /// /// Driven through the properties the cards bind rather than through a click, because what is worth /// holding here is the rule; the pointer is put on the gesture itself in the test below. A click would /// otherwise be testing Avalonia's SelectedItem binding, which is not this application's code. /// /// [Fact] public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid() { await vault.MoveHostToGroupCommand.ExecuteAsync( new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId)); // Re-found after the move, because the reload it ends with replaces every row in the list. var production = vault.VisibleGroups.Single(); vault.SelectedGroup = production; vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it"); vault.VisibleHosts.Select(row => row.Label) .ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group"); vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on"); vault.ShowsGroupActions.ShouldBeTrue(); vault.OpenGroupCommand.Execute(production); vault.VisibleHosts.Select(row => row.Label) .ShouldBe(["prod-db"], "only what is filed under the group that is open"); vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]); vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more"); vault.GroupTarget.ShouldBeSameAs( production, "so the buttons fall back to the group whose contents are showing"); // Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the // same job and went with the change. vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group); vault.VisibleHosts.Select(row => row.Label) .ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain"); vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected"); vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown"); } /// /// The two grids share one selection, so at most one card on the screen is ever lit. /// /// /// /// They are two ListBoxes, each holding a selection of its own and each drawing it the same way. /// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of /// which only one acts on whichever card the eye has settled on. The vault is what joins them. /// /// /// Driven on the screen rather than on the view model alone, because half of the rule lives in the /// controls: clearing the property has to reach the list that is drawing the card, and a selection /// nulled in the view model while the card stays highlighted is the exact failure this is about. /// /// [Fact] public async Task TheHostAndGroupGridsShareOneSelection() { await OnTheGridAsync((screen, _) => { var host = Row(vault, "stage-web"); vault.OpenHostPaneCommand.Execute(host); Dispatcher.UIThread.RunJobs(); vault.SelectedGroup = vault.VisibleGroups.Single(); Dispatcher.UIThread.RunJobs(); vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else"); vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told"); screen.HostGrid.SelectedItem.ShouldBeNull(); CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark"); vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group"); vault.SelectedHost = host; Dispatcher.UIThread.RunJobs(); vault.SelectedGroup.ShouldBeNull("and the same in the other direction"); screen.GroupGrid.SelectedItem.ShouldBeNull(); GroupCard(screen).IsSelected.ShouldBeFalse(); vault.ShowsGroupActions.ShouldBeFalse("so the group's own two buttons have nothing to act on"); }); } /// /// EDIT takes the group as an argument now, so that the phone can open the editor on a heading without /// selecting a group and losing the host selection to it — see VaultViewModel.EditGroup. The /// button beside the cards passes nothing and means "the card that is selected", which is the half of /// that change that would fail silently: a command refusing a null parameter is a button that never /// fires, and nothing about the markup would say so. /// [Fact] public async Task TheGroupsEditButtonStillActsOnTheSelectedCard() { await OnTheGridAsync((screen, _) => { vault.SelectedGroup = vault.VisibleGroups.Single(); Dispatcher.UIThread.RunJobs(); var edit = screen.GetVisualDescendants() .OfType