Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
T
jaap-jan 2a56ae3efb Put a host inside the group it is filed under, rather than beside it
The group cards were headings with a navigation gesture bolted on. Opening one
narrowed the grid to its hosts, but the level above already held every host in
the keychain — so a card could only ever subtract, filing something changed
nothing but a chip, and a keychain with forty machines was forty cards however
carefully anybody had arranged them. The trail said ALL HOSTS and meant it.

So the grid holds one level of the tree, the way a directory pane holds one
directory. A host filed under a group is inside that group and is not also on
the screen the group's card sits on; the outermost level is what nothing has
been filed into. A group is a place now, and the cards, the trail and the drop
target were already the vocabulary for saying so.

The find box is the exception and had to be one. Typed into, it searches the
open group and everything under it, which from the outermost level is every
machine in the keychain. A box scoped to the level it was typed on would answer
"no host matches that" about a host this keychain has got, and finding a machine
without first remembering where it was filed is most of what the box is for.
The accent chip on a card is what tells a searched-up host from one that lives
at this level.

Two things came free with the change and are handled rather than left.

The phone would have broken. Its list binds SidebarRows, which was a projection
over VisibleHosts, and it has no group cards and nowhere to open one into — so
level-scoping would have left it drawing only the hosts nobody had filed.
RebuildSidebarRows takes its own pass over the hosts now, narrowed by the vault
switches and the box and by nothing else, which is the whole tree flattened
under headings: exactly what it drew before.

And anything created inside a group disappeared the moment it was saved. The
host editor opens on the group the screen is about rather than only on a
selected card, and + NEW GROUP defaults its parent to the group that is open.
Deliberately not the selected card there: a highlighted card is what EDIT and
DELETE are aimed at, and reading it as "and the next group goes inside it" would
nest one because somebody had clicked something. The host editor takes both
because it always took the selection, and its picker shows the answer before
anything is written.

Dropping a host on a group card now takes the card off the grid, and the
selection goes with it — Connect, Edit and Delete all read that property and
none of them should be aimed at a card that has left the screen. The status line
is what says where it went, which is why the manual check now asks for it to be
read. Coming back out through the editor lands the host on this level again, and
there the selection survives.

An empty grid has two more things it can say: that every host is filed away,
which the level-at-a-time grid made reachable and which is not the same sentence
as "there are none", and that nothing under this group matches what was typed —
with ALL HOSTS named as the way to widen it.

Not changed, and next door: a group card counts the hosts filed directly under
it, so a group holding only subgroups reads "0 hosts". That was already true and
is more visible now that its subgroups' hosts are not spilled onto the level
above.

1522 tests pass. Four are new — the level rule and the phone's flat list
asserted together, the box reaching two levels down, the all-filed sentence, and
where a host and a group made inside a group end up. MovingAHostToAGroup was
asserting the old outcome and is rewritten rather than adjusted: it held that
the host stayed selected, and what it holds now is that the host leaves the
level it came from.
2026-08-04 09:51:55 +02:00

529 lines
23 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 all three of
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
/// suite.
/// </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.
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
edit.Command!.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
});
}
/// <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>
/// 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, 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 aims its EDIT and DELETE at 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 SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
{
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 EDIT and DELETE act on");
vault.ShowsGroupActions.ShouldBeTrue();
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 the buttons fall 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");
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
/// <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");
});
}
// ---- 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<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
/// <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);
}
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);
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
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");
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);
}
/// <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);
}
}