Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
T

558 lines
24 KiB
C#

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;
/// <summary>
/// How the board of host cards answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on <see cref="HostsScreen"/>
/// now, and so is every handler that was wired to them. See <c>HostsScreen.axaml.cs</c>.
/// </para>
/// <para>
/// ◆ 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 <c>VaultViewModel.HostSections</c>, 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 — <c>OpenGroupCommand</c>, <c>VisibleGroups</c>, <c>GroupTrail</c>,
/// <c>SelectedGroup</c>, <c>GroupCrumbViewModel</c>, and the drag command <c>MoveHostToGroupCommand</c> dragged
/// onto — is no longer on <c>VaultViewModel</c> at all, confirmed unreachable from both heads' markup before
/// removal; <c>GroupFilter</c> 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 <c>HostSectionsTests</c> instead, which is where a heading now belongs.
/// </para>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, 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 <em>before</em> 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.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> 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.
/// </para>
/// </remarks>
public sealed class HostGridTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
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;
/// <inheritdoc />
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<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
knownHosts.Close();
await workspace.DisposeAsync();
await vault.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// 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.
/// </remarks>
[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<MenuItem>()
.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");
});
}
/// <remarks>
/// The space around the cards and below them is part of <c>Board</c> 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 <c>Board</c> itself, which is where the code-behind's tunnelled
/// handler is attached; see <c>HostsScreen.axaml.cs</c>.
/// </remarks>
[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<ListBox>()
.Where(list => list.Classes.Contains("sectioncards"))
.Select(list => list.ContextMenu)
.OfType<ContextMenu>()
.ShouldAllBe(menu => !menu.IsOpen, "no section's menu opened over the empty space");
});
}
/// <summary>
/// Choosing a host costs nothing, and the pencil on its card is what spends the 320 pixels.
/// </summary>
/// <remarks>
/// <para>
/// 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 <em>deliberate</em>.
/// </para>
/// <para>
/// 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
/// <c>#Board</c> path that resolves to nothing compiles, draws, and does nothing when pressed. See the
/// remark on that idiom at the top of <c>HostsScreen.axaml</c>.
/// </para>
/// </remarks>
[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<Button>()
.First(button => button.Classes.Contains("rowedit"));
pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
pencil.Command.Execute(pencil.CommandParameter);
vault.IsDrawerOpen.ShouldBeTrue();
vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
});
}
/// <remarks>
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
/// selection that goes away entirely has to take it with it.
/// </remarks>
[Fact]
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
{
await OnTheBoardAsync((_, _) =>
{
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
vault.IsDrawerOpen.ShouldBeTrue();
vault.SelectedHost = null;
vault.IsDrawerOpen.ShouldBeFalse();
vault.SelectedHost = Row(vault, "stage-web");
vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
});
}
// ---- ◆ Choosing more than one card ----
//
// The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
// with a pointer instead of a long press. What these hold is the half that belongs to this head: which
// gesture means what, and the rule that keeps a section list's own selection and the set from ever both
// being about something at the same moment. See HostsScreen.axaml.cs.
/// <remarks>
/// <para>
/// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
/// also moved a section's own <c>ListBox</c> mark would light the card it had just unticked and open the
/// drawer on a machine somebody is removing from a set. Stopping that is why the press is handled on the
/// way down rather than acted on as it bubbles.
/// </para>
/// <para>
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
/// card is in play and every command on this screen is about the same host.
/// </para>
/// </remarks>
[Fact]
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
{
await OnTheBoardAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var second = Row(vault, "stage-web");
vault.SelectedHost = first;
Dispatcher.UIThread.RunJobs();
Click(CardFor(screen, second), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1);
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(2);
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
Click(CardFor(screen, first), window);
vault.IsChoosingHosts.ShouldBeFalse("a plain click drops the set");
vault.SelectedHost.ShouldBeSameAs(first, "and selects the card it landed on, as it always has");
});
}
/// <remarks>
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
/// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
/// the board's own — <c>VaultViewModel.HostBoardOrder</c> — so "between" means between as the cards are
/// laid out, across sections if the run crosses one.
/// </remarks>
[Fact]
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
{
await AddHostAsync("dev-box");
await OnTheBoardAsync((screen, window) =>
{
var order = vault.HostBoardOrder.ToList();
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
Click(CardFor(screen, order[0]), window);
Click(CardFor(screen, order[2]), window, RawInputModifiers.Shift);
vault.ChosenHostCount.ShouldBe(3);
order.ShouldAllBe(row => row.IsChosen);
Click(CardFor(screen, order[1]), window, RawInputModifiers.Shift);
vault.ChosenHostCount.ShouldBe(2);
order[2].IsChosen.ShouldBeFalse("the run is re-measured from the anchor, not extended");
});
}
/// <summary>
/// The band, dragged out over the space around the cards.
/// </summary>
/// <remarks>
/// <para>
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
/// selection or the start of a drag of that host, and the band is what the space between and below them
/// is for. The rectangle ticks what it touches rather than what it swallows.
/// </para>
/// <para>
/// The second half is the same press without the drag: a click on the empty space is how somebody who
/// never finds Esc gets out of a selection.
/// </para>
/// </remarks>
[Fact]
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
{
await OnTheBoardAsync((screen, window) =>
{
var first = CardFor(screen, Row(vault, "prod-db"));
var last = CardFor(screen, Row(vault, "stage-web"));
var topLeft = Corner(first, window);
var bottomRight = Corner(last, window)
+ new Point(last.Bounds.Width, last.Bounds.Height);
// Below every card, so the press lands on the scroller rather than on a list item.
var from = bottomRight.WithY(bottomRight.Y + 24);
var to = topLeft + new Point(2, 2);
// The button has to be named on the moves as well as on the press: a headless move carries the
// button state in its modifiers, and one sent without it is the pointer being let go of.
window.MouseDown(from, MouseButton.Left);
window.MouseMove(new Point(to.X, from.Y), RawInputModifiers.LeftMouseButton);
window.MouseMove(to, RawInputModifiers.LeftMouseButton);
Dispatcher.UIThread.RunJobs();
vault.ChosenHostCount.ShouldBe(2, "the band was over both cards");
window.MouseUp(to, MouseButton.Left);
vault.ChosenHostCount.ShouldBe(2, "and letting go keeps what it was over");
window.MouseDown(from, MouseButton.Left);
window.MouseUp(from, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.IsChoosingHosts.ShouldBeFalse("a press and a release with no band between them is a click");
});
}
/// <summary>
/// One menu with two halves, and which half is drawn is whether anything is ticked.
/// </summary>
/// <remarks>
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
/// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
/// must never happen is both being offered at once. A right click outside the set is what drops it, so
/// the two are never both meaningful.
/// </remarks>
[Fact]
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
{
await OnTheBoardAsync((screen, window) =>
{
var ticked = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
var tickedCard = CardFor(screen, ticked);
Click(tickedCard, window, RawInputModifiers.Control);
RightClick(tickedCard, window);
var menu = SectionMenuFor(tickedCard).ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
var offered = menu.Items.OfType<MenuItem>().Where(item => item.IsVisible).ToList();
offered.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteChosenHostsCommand));
offered.ShouldNotContain(
item => ReferenceEquals(item.Command, vault.DeleteHostCommand),
"the entries about the selection are not offered beside the entries about the set");
menu.Close();
var otherCard = CardFor(screen, other);
RightClick(otherCard, window);
vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");
SectionMenuFor(otherCard).ShouldNotBeNull().Items.OfType<MenuItem>()
.Where(item => item.IsVisible)
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
});
}
/// <remarks>
/// Ctrl+A is every card <em>on the board</em> and not every host in the keychain, which is the difference
/// that matters the moment there is something in the find box: a shortcut that quietly ticked the
/// machines it is not showing would be the worst possible input to Delete. Esc is the way back out, and
/// <c>Board</c> is where both are handled — Ctrl+A in the find box above has to go on selecting text.
/// </remarks>
[Fact]
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
{
await AddHostAsync("dev-box");
vault.HostFilter = "prod";
await OnTheBoardAsync((screen, window) =>
{
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
screen.Board.Focus();
Dispatcher.UIThread.RunJobs();
screen.Board.IsFocused.ShouldBeTrue("the keys are the board's");
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1, "the one card being drawn, not the three hosts there are");
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
vault.IsChoosingHosts.ShouldBeFalse();
});
}
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)
{
var at = Centre(card, window);
((Window)window).MouseDown(at, MouseButton.Right);
((Window)window).MouseUp(at, MouseButton.Right);
}
/// <summary>A press and a release on one card, with whatever was being held down at the time.</summary>
/// <remarks>
/// Both halves, because the two say different things here: the press is where a modifier is read and
/// where the set is dropped, and the release is where a press on a ticked card that turned out not to be
/// a drag collapses onto it.
/// </remarks>
private static void Click(Visual card, Window window, RawInputModifiers held = RawInputModifiers.None)
{
var at = Centre(card, window);
window.MouseDown(at, MouseButton.Left, held);
window.MouseUp(at, MouseButton.Left, held);
Dispatcher.UIThread.RunJobs();
}
private static Point Corner(Visual control, Visual window) =>
control.TranslatePoint(default, window)
?? throw new InvalidOperationException("the control is not in this window's tree");
private Task OnTheBoardAsync(Action<HostsScreen, Window> 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);
/// <summary>The card for a host, out of whichever section's own list is drawing it.</summary>
private static ListBoxItem CardFor(Visual screen, HostRowViewModel row) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, row));
/// <summary>The <c>ContextMenu</c> of the section list a card belongs to.</summary>
/// <remarks>
/// Every section draws its own <c>ListBox</c> — see the remarks on <c>HostSectionViewModel</c> — so
/// there is no longer one menu for the whole board; this is what "the menu" now means for a given card.
/// </remarks>
private static ContextMenu? SectionMenuFor(ListBoxItem card) =>
card.FindAncestorOfType<ListBox>()?.ContextMenu;
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");
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
private async Task AddHostAsync(string label)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
}
/// <remarks>
/// Two hosts and a group, so there is a heading in the board and a selection to move off. Neither host
/// is filed under the group — it stays in the seed only because several of the suites in this project
/// still ask for one to exist, empty though it is here.
/// </remarks>
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);
}
}