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 board 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. /// /// /// ◆ v5: THE GROUP-CARD HALF OF THIS SUITE IS GONE, AND IT IS NOT REPLACED HERE. The desktop used to hold a /// wrap of group cards above the hosts — one level of the tree at a time, opened by a double-click, filed /// into by dragging a host card onto one. All of that left with the cards: v5 draws every group as a /// section heading instead, see VaultViewModel.HostSections, and a host is filed through its own /// editor or the chosen-hosts menu's "Change group…" rather than a drag. The card-navigation surface this /// was written against — OpenGroupCommand, VisibleGroups, GroupTrail, /// SelectedGroup, GroupCrumbViewModel, and the drag command MoveHostToGroupCommand dragged /// onto — is no longer on VaultViewModel at all, confirmed unreachable from both heads' markup before /// removal; GroupFilter stays, since group-heading commands still fall back to it. Section-level /// coverage — flattening order, the headerless invariant, per-section collapse, filter composition, monogram /// stability — is in HostSectionsTests instead, which is where a heading now belongs. /// /// /// 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 the commands on both of /// that menu's halves read the vault's 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 board 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 OnTheBoardAsync((screen, window) => { var first = Row(vault, "prod-db"); var other = Row(vault, "stage-web"); vault.SelectedHost = first; var card = CardFor(screen, other); RightClick(card, window); vault.SelectedHost.ShouldBeSameAs(other); var menu = SectionMenuFor(card).ShouldNotBeNull(); menu.IsOpen.ShouldBeTrue(); // The commands are the vault's, which is the other half of putting the menu on each section's // own list rather than in the card's 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.IsVisible && 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 and below them is part of Board rather than of any one section's /// list, 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 Board itself, which is where the code-behind's tunnelled /// handler is attached; see HostsScreen.axaml.cs. /// [Fact] public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing() { await OnTheBoardAsync((screen, _) => { var selected = Row(vault, "prod-db"); vault.SelectedHost = selected; screen.Board.RaiseEvent(new ContextRequestedEventArgs { RoutedEvent = Control.ContextRequestedEvent, Source = screen.Board, }); vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on"); screen.GetVisualDescendants().OfType() .Where(list => list.Classes.Contains("sectioncards")) .Select(list => list.ContextMenu) .OfType() .ShouldAllBe(menu => !menu.IsOpen, "no section's menu opened over the empty space"); }); } /// /// Choosing a host costs nothing, and the pencil on its card is what spends the 320 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 /// #Board path that resolves to nothing compiles, draws, and does nothing when pressed. See the /// remark on that idiom at the top of HostsScreen.axaml. /// /// [Fact] public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection() { await OnTheBoardAsync((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