Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
ci / build and test (push) Successful in 1m21s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 32s

This commit is contained in:
2026-08-03 16:22:45 +02:00
6 changed files with 549 additions and 80 deletions
@@ -3,6 +3,7 @@ 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;
@@ -208,32 +209,141 @@ public sealed class HostGridTests : IAsyncLifetime
}
/// <remarks>
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
/// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
/// what is worth holding is the rule — the filter is a property of the grid, and it also moves the
/// selection the group's own EDIT and DELETE act on. A click would test Avalonia's <c>SelectedItem</c>
/// binding, which is not this application's code.
/// <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 ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt()
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
{
var production = vault.Groups.Single();
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
vault.MoveHostToGroupCommand.Execute(
new HostGroupMove(Row(vault, "prod-db"), production.EntityId));
// Re-found after the move, because the reload it ends with replaces every row in the list.
var production = vault.VisibleGroups.Single();
vault.GroupFilter = production;
vault.SelectedGroup = production;
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
vault.VisibleHosts.Count.ShouldBe(2, "so the grid still holds the hosts outside it");
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 chosen group");
.ShouldBe(["prod-db"], "only what is filed under the group that is open");
vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.IsFilteredByGroup.ShouldBeTrue();
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
vault.ClearGroupFilterCommand.Execute(null);
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");
vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group");
vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
// 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.Count.ShouldBe(2, "ALL HOSTS brings back the hosts outside the group");
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"]);
}
// ---- Helpers ----
@@ -292,6 +402,12 @@ public sealed class HostGridTests : IAsyncLifetime
},
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>()
@@ -304,6 +420,27 @@ public sealed class HostGridTests : IAsyncLifetime
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()
{