Merge branch 'claude/group-creation-hosts-dragdrop-56c6a3'
ci / build and test (push) Canceled after 1m6s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s

This commit is contained in:
2026-08-03 15:50:12 +02:00
8 changed files with 432 additions and 147 deletions
@@ -2,6 +2,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
@@ -127,30 +128,85 @@ public sealed class HostGridTests : IAsyncLifetime
}
/// <remarks>
/// A heading is an item in the same list and the control will happily select it, but it is not a host —
/// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
/// or act on a machine somewhere else in the grid.
/// <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 ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
{
await OnTheGridAsync((screen, window) =>
await OnTheGridAsync((screen, _) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
var heading = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is SidebarGroupHeader);
RightClick(heading, window);
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>
/// 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
@@ -182,6 +238,32 @@ public sealed class HostGridTests : IAsyncLifetime
// ---- 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);
@@ -325,22 +325,45 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
});
}
/// <summary>
/// The grid is cards and nothing else, whatever the vault has been filed into.
/// </summary>
/// <remarks>
/// Headings are items in the same list as the cards, drawn from a different template, and they span a
/// whole row of the wrap rather than sitting in the flow as another card. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// <para>
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
/// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now.
/// This asserts the grid's contents rather than only measuring them, because a heading that came back
/// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar
/// that spans the width is as inside it as a card is.
/// </para>
/// <para>
/// Measured with every host filed, which is the shape that used to produce the most headings.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithGroupHeadingsInTheGrid()
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
foreach (var host in vault.Hosts.ToArray())
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(host, vault.Groups[0].EntityId));
}
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
await OnTheHostsScreenAsync((screen, _) =>
{
var rows = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Select(item => item.DataContext)
.ToList();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
rows.ShouldNotBeEmpty("the seed has to put hosts in the grid");
rows.ShouldAllBe(row => row is HostRowViewModel);
});
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three groups and every host filed"));
}
/// <remarks>
@@ -3584,9 +3584,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
/// <remarks>
/// What dragging a row onto a heading does. It is the same write the editor makes — one field of the
/// host, pushed straight away — reached without opening a form, because filing thirty imported machines
/// through the editor is thirty rounds of open, pick, save.
/// What dragging a host card onto a group card does. It is the same write the editor makes — one field
/// of the host, pushed straight away — reached without opening a form, because filing thirty imported
/// machines through the editor is thirty rounds of open, pick, save.
/// </remarks>
[Fact]
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
@@ -3605,15 +3605,21 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
// Under the group's own heading now, which is the thing the drop was aiming at.
// Under the group's own heading now, which is what the phone's list draws.
vault.SidebarRows.OfType<SidebarGroupHeader>()
.Single(header => header.GroupId == group)
.Count.ShouldBe(1);
// And back out again, which is what the ungrouped heading is a target for.
// And the name on the card, which is what the desktop's grid draws instead of that heading — the one
// thing on screen that changes where the host was dropped rather than where it came from.
vault.Hosts.Single().GroupLabel.ShouldBe("production");
vault.Hosts.Single().HasGroup.ShouldBeTrue();
// And back out again, which is what the host's own editor is for now that the drop has one target.
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
}
/// <remarks>
@@ -3670,9 +3676,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Groups.ShouldBeEmpty();
vault.HasGroups.ShouldBeFalse();
// The host keeps the id, which is what makes this cheap; the sidebar is what resolves it to nothing.
// The host keeps the id, which is what makes this cheap; the list is what resolves it to nothing.
vault.Hosts.Single().Host.GroupId.ShouldBe(groupId);
vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
// The card says the same thing the phone's list does: nothing. An id nobody can name is drawn as no
// group rather than as a GUID on a chip.
vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
}
/// <remarks>