Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
T
jaap-jan c859705b83
ci / android head (push) Successful in 4m1s
ci / build and test (push) Successful in 6m25s
ci / api image (push) Successful in 23s
ci / desktop nightly (push) Successful in 2m4s
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on
somebody's behalf.

The board draws a heading per vault and sorts each vault's hosts by that
vault's own groups. A group is an item of one vault, so drawing every
group of every vault in one run flattened away containment that already
exists: two vaults each holding a "production" were two headings with the
same word on them, told apart by a small grey name beside each. Single-
vault sessions are unchanged. There is now one "No group" heading per
vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey
rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts
folding every other vault's away with them.

A Ctrl-click starts its set with the card the board has lit. The board
always has a selection — a fresh unlock lights the first host so CONNECT
has something under it — so "one card lit, nothing ticked" is the state
this screen opens in, and ticking a second card there produced a set of
one: the machine the user was looking at was left out of the count and
out of everything Delete, Move and Change group then ran over.

The card tooltip says the host's name as well as its address. 214 pixels
of card trims the name, and the tooltip was the one place it was not said
in full.

The card's floor drops from 120 to 104. A card with no tags measures
about 87, so the mock's number was thirty pixels of air under every card
on a board that is scrolled.

"Move to another vault…" joins the board's context menu for one host.
Moving one machine meant opening its pane and finding the menu inside it,
while moving six was a right click away — the one-host case was the
harder of the two.

And the Hetzner import asks which vault and which key. A project belongs
to a team more often than to a person, so forty servers landing wherever
the session happens to be standing is the wrong default; ImportHostsAsync
takes the target vault, and the ssh_config importer passes null and is
unchanged. The key is the half Hetzner cannot supply and the keychain
usually already holds — the public half went to Hetzner when the servers
were made — so the import points every host at it rather than bringing
one. It defaults to no key rather than guessing, and says so when the key
it was given lives in a different vault from the hosts.
2026-08-26 11:54:29 +02:00

574 lines
25 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>
/// ◆ <b>And the set the first Ctrl-click starts has the lit card in it.</b> The board always has a
/// selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card
/// lit, nothing ticked" is the state this screen opens in. A Ctrl-click there used to produce a set of
/// one, leaving the machine the user was looking at out of the count and out of everything Delete, Move
/// and Change group then acted on. See <c>VaultViewModel.ExtendHostChoice</c>.
/// </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 CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
{
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(2, "the card the board had lit is in the set the Ctrl-click began");
first.IsChosen.ShouldBeTrue("which is the half that used to be left out of it");
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(1, "the same click again takes the tick off");
first.IsChosen.ShouldBeFalse("the lit card is not folded back in over a set that already exists");
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(2);
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);
// Lit first, and it matters: a Ctrl-click folds the lit card into the set it starts — see
// ExtendHostChoice — so ticking one card while another is lit would leave no card outside the
// set for the second half of this test to right-click.
vault.SelectedHost = ticked;
Dispatcher.UIThread.RunJobs();
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);
}
}