using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Interactivity; 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"); }); } /// /// 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"); } /// /// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels. /// /// /// /// The two halves are one rule and are asserted together, because either alone would pass on a broken /// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the /// second. What is being held is that opening is deliberate. /// /// /// Driven through the card's own button rather than by executing the command, since the thing most /// likely to rot is the binding that reaches out of the item template to the vault's command — a /// $parent[ListBox] path that resolves to nothing compiles, draws, and does nothing when pressed. /// /// [Fact] public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection() { await OnTheGridAsync((screen, _) => { var host = Row(vault, "stage-web"); vault.SelectedHost = host; vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane"); // The button is hidden until the pointer is on the card, so a click cannot be synthesised at a // point: what a headless run can reach is the control and the command behind it. var pencil = CardFor(screen, host) .GetVisualDescendants() .OfType