Public Access
Merge branch 'claude/group-creation-hosts-dragdrop-56c6a3'
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user