Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
T
jaap-jan 507cd9ff88 Choose more than one host card on the desktop, the way the phone already can
The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action
bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could
fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the
context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing
out a vault was eleven rounds of the deletion question.

So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run
between the anchor and the card, and drags a band out over the space between and below the cards to tick
everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and
Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on
screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked
before Delete is pressed.

TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are
answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a
Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine
somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case
is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary
click exactly one card is in play, which is what makes every command on the screen unambiguous again.

The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts.
Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them
and for the same reason; the other four read better for a count. A right click on a card outside the set
drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked
behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the
other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries,
and a strip repeating a menu that already exists would be a second home for the wording that matters most.
What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the
actions are.

A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now,
and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open
editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving
whichever card the pointer happened to be holding and leaving the other five where they are is a gesture
that quietly does a fraction of what it looks like it does, and the five left behind look filed.

The three panels the set's actions raise had never been drawn in a window: the vault picker with its key
question, the group picker, and the deletion question. All three sit above the grid rather than over it,
which is the arrangement the GROUPS section and the phone's list already use and for the reason written
there — the ticked cards are the information the question exists to give, so the grid shortens instead.

A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the
run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately
above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with
the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work
with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked
about and drops the question when the set stops being them — the question rather than the set, because what
somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion
arriving mid-question and shrinking the set under it.

VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests
drive real pointer and key input through the headless window — the modifier click and what it must not do to
the selection, the run and its re-measurement from the anchor, the band and the click that drops the set,
Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests
measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host
first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's
write and its refusal, and the deletion question dropping itself.

manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the
gestures — the rectangle actually being painted and the tick and the fill being legible together are the two
things the harness cannot look at.
2026-08-06 14:11:48 +02:00

975 lines
43 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 grid 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>
/// 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 screen's menus 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 — and the group cards have the same menu with a fallback behind it that makes getting it wrong
/// quieter still.
/// </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 grid 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 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.
//
// Drawn rather than merely present, because this menu carries a second Edit… that is about the
// ticked set rather than about the selection; with nothing ticked that one is collapsed. See
// TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise.
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>
/// <para>
/// The space around the cards is part of the same <c>ListBox</c>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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();
});
}
/// <summary>
/// The same rule on the cards above, where getting it wrong is quieter and worse.
/// </summary>
/// <remarks>
/// <para>
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, and
/// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
/// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
/// </para>
/// <para>
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
/// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
/// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
/// </para>
/// </remarks>
[Fact]
public async Task ARightClickSelectsTheGroupUnderThePointer()
{
await AddGroupAsync("staging");
await OnTheGridAsync((screen, window) =>
{
var first = GroupRow(vault, "production");
var other = GroupRow(vault, "staging");
vault.SelectedGroup = first;
RightClick(CardFor(screen, other), window);
vault.SelectedGroup.ShouldBeSameAs(other);
var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
var items = menu.Items.OfType<MenuItem>().ToList();
var open = items.Single(item => item.Header is "Open");
open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");
var edit = items.Single(item => item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditGroupCommand);
edit.Command!.Execute(null);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe(
other.Label, "the card that was right-clicked, not the one selected before");
});
}
/// <remarks>
/// The space around the group cards, where a menu would be at its most misleading: nothing is under the
/// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
/// trail ends with — which, once it is open, is not a card on screen at all.
/// </remarks>
[Fact]
public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
{
await OnTheGridAsync((screen, _) =>
{
var selected = GroupRow(vault, "production");
vault.SelectedGroup = selected;
screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
{
RoutedEvent = Control.ContextRequestedEvent,
Source = screen.GroupGrid,
});
vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
screen.GroupGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <summary>
/// A host held over a group card would be filed there, and one held over another host card would not.
/// </summary>
/// <remarks>
/// <para>
/// The group headings that used to sit between the cards are gone — see
/// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — 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.
/// </para>
/// <para>
/// What is <em>not</em> 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 <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
/// covers is in docs/manual-checks.md 7.6.
/// </para>
/// </remarks>
[Fact]
public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
{
await OnTheGridAsync((screen, _) =>
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, (IReadOnlyList<HostRowViewModel>)[Row(vault, "prod-db")]));
var onto = screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.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");
});
}
/// <remarks>
/// <para>
/// The rule one press was split into two gestures for. Selecting a group marks 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.
/// </para>
/// <para>
/// 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 <c>SelectedItem</c> binding, which is not this application's code.
/// </para>
/// </remarks>
[Fact]
public async Task SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
{
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 a group command with no argument acts on");
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 an unaimed command falls 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");
}
/// <summary>
/// The two grids share one selection, so at most one card on the screen is ever lit.
/// </summary>
/// <remarks>
/// <para>
/// They are two <c>ListBox</c>es, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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();
});
}
/// <remarks>
/// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
/// the whole of both now — the menu came second and did the same job better, since it acts on the card
/// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
/// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
/// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
/// </remarks>
[Fact]
public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
{
await OnTheGridAsync((screen, _) =>
{
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
screen.GetVisualDescendants()
.OfType<Button>()
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
});
}
/// <remarks>
/// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
/// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
/// between the two grids that fallback would quietly unselect a group card every time a sync landed.
/// </remarks>
[Fact]
public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
{
var production = vault.VisibleGroups.Single();
vault.SelectedGroup = production;
vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");
await vault.LoadAsync(Token);
vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
vault.SelectedGroup
.ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
.EntityId.ShouldBe(production.EntityId);
}
/// <remarks>
/// The gesture, performed. It is wired in the control rather than bound in the markup — which is exactly
/// the sort of wiring that compiles whether or not it is connected to anything — and the first half of
/// what it asserts is the half that would go unnoticed: one press must still only select, or the split
/// bought nothing.
/// </remarks>
[Fact]
public async Task DoubleClickingAGroupCardOpensIt()
{
// One press, in a window of its own, because a second pair of clicks in the same one is the gesture
// this is separating that press from.
await OnTheGridAsync((screen, window) =>
{
var centre = Centre(GroupCard(screen), window);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.SelectedGroup.ShouldNotBeNull("one press selects the card");
vault.GroupFilter.ShouldBeNull("and opens nothing");
});
await OnTheGridAsync((screen, window) =>
{
var centre = Centre(GroupCard(screen), window);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.GroupFilter.ShouldNotBeNull().Label.ShouldBe("production");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
// And out again through the trail as it is actually drawn, rather than through the command. A
// crumb is an item in a template and the command it presses is the vault's, so the two are
// joined by a $parent binding — which is a string that compiles whether or not it resolves, and
// would leave a trail of buttons that do nothing.
var back = screen.GetVisualDescendants()
.OfType<Button>()
.First(button => button.DataContext is GroupCrumbViewModel { Group: null });
back.Command.ShouldNotBeNull("the crumb reached the vault's command").Execute(back.CommandParameter);
vault.GroupFilter.ShouldBeNull("ALL HOSTS is the way back out");
vault.VisibleHosts.Count.ShouldBe(2);
});
}
/// <summary>
/// The cards are one level of the tree, and the trail is how that level was reached.
/// </summary>
/// <remarks>
/// The grid used to draw every group at once, which was the only honest thing to do while a card was a
/// filter: a filter nobody can see is a filter nobody can turn off. Once opening a group became
/// navigation the cards became its contents — and a level with no name and no way back is a grid that
/// has quietly hidden things, which is what the trail is for.
/// </remarks>
[Fact]
public async Task OpeningANestedGroupShowsWhatIsInsideItAndTheWayBack()
{
await AddGroupAsync("estate");
await FileGroupUnderAsync("production", "estate");
vault.VisibleGroups.Select(row => row.Label)
.ShouldBe(["estate"], "the outermost groups, and production is not one of them any more");
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"], "what is inside estate");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());
vault.HasVisibleGroups.ShouldBeFalse("production has nothing inside it, so the cards fold away");
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate", "production"]);
// A middle crumb goes back one level rather than all the way out, which is the whole reason the
// trail is a row of buttons instead of a sentence saying where you are.
vault.OpenGroupCommand.Execute(vault.GroupTrail[1].Group);
vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"]);
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
}
/// <summary>
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 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>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
/// </para>
/// </remarks>
[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<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. Without that the flag would survive a
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
/// which is the behaviour the pencil exists to remove.
/// </remarks>
[Fact]
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
{
await OnTheGridAsync((_, _) =>
{
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 the 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 the <c>ListBox</c>'s own 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 OnTheGridAsync((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");
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was 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 grid's own, so "between" means between as the cards are laid out.
/// </remarks>
[Fact]
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
{
await AddHostAsync("dev-box");
await OnTheGridAsync((screen, window) =>
{
var order = vault.VisibleHosts.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 — a card is 232 pixels wide,
/// and a band that had to contain one would tick nothing at all when drawn down a column.
/// </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 OnTheGridAsync((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 Delete… asking about the card under the pointer
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
/// </remarks>
[Fact]
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
{
await OnTheGridAsync((screen, window) =>
{
var ticked = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
Click(CardFor(screen, ticked), window, RawInputModifiers.Control);
RightClick(CardFor(screen, ticked), window);
var menu = screen.HostGrid.ContextMenu.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();
RightClick(CardFor(screen, other), 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");
menu.Items.OfType<MenuItem>()
.Where(item => item.IsVisible)
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
});
}
/// <remarks>
/// Ctrl+A is every card <em>on the screen</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 the grid 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 OnTheGridAsync((screen, window) =>
{
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
screen.HostGrid.Focus();
Dispatcher.UIThread.RunJobs();
screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid'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();
});
}
/// <remarks>
/// The drag carries a list because a drag that starts on a ticked card carries every ticked card, and the
/// group cards have to answer for the whole of it. Refused only when there is nothing in it left to file:
/// a set with one host from somewhere else in it is a real move, and offering the "no" cursor for it
/// would be a drop the user can see the point of and cannot make.
/// </remarks>
[Fact]
public async Task AGroupCardTakesAWholeSetOfDraggedHosts()
{
await OnTheGridAsync((screen, _) =>
{
IReadOnlyList<HostRowViewModel> set = [Row(vault, "prod-db"), Row(vault, "stage-web")];
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, set));
var onto = screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
var over = Over(onto, carried);
over.DragEffects.ShouldBe(DragDropEffects.Move, "both of them can be filed there");
});
}
// ---- Helpers ----
/// <summary>The same in-process format the screen's own drag carries.</summary>
/// <remarks>
/// Declared again here rather than made visible, because what the two have in common is the contract —
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
/// started carrying something else under it.
/// </remarks>
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
/// <remarks>
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
/// </remarks>
private static DragEventArgs Over(Interactive target, DataTransfer carried)
{
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
target.RaiseEvent(over);
return over;
}
private static void RightClick(Visual row, Visual window)
{
var at = Centre(row, 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 OnTheGridAsync(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);
private static ListBoxItem GroupCard(HostsScreen screen) =>
screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
private static ListBoxItem CardFor(Visual screen, object row) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, row));
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <remarks>
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
/// </remarks>
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
vault.VisibleGroups.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");
private async Task AddGroupAsync(string label)
{
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
private async Task FileGroupUnderAsync(string group, string parent)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <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 list and a selection to move off.</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);
}
}