Public Access
Take the group headings out of the host grid, and drop onto a group card
A first group turned the wrap of host cards into an accordion: SidebarRows interleaves a full-width fold-away heading — chevron, name, count — between the cards, and in a grid that reads as a dropdown somebody left open. The desktop grid binds VisibleHosts now. The headings and the fold stay for the phone, whose list has no room for the row of group cards the desktop draws above the grid. What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once per reload like the tag names, drawn as an accent chip and absent from a host in no group — or in one that has been deleted, which is the same thing to look at. What a heading also was is the drop target, and that moves to the group cards. Two things go with it. A host dropped onto another host card used to be filed beside it, which was legible while a heading named the band of cards it landed in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged back out of a group; the way out is the picker in its own editor, which is the one place "no group" can be said in words. A drag held at either edge of the grid scrolls it. Without that the gesture only works for whoever can see both ends of it: the group cards are the first thing in the scroller, the host may be the fortieth card down, and a drag cannot use the wheel. A step per drag event rather than a timer, so it follows the pointer and stops when it stops. The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings asserts the grid's contents rather than only measuring them, because a heading that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost raises a real DragOver over both kinds of card and checks the effects and the mark — the nearest a headless test gets to a gesture no headless test can synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
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>
|
||||
|
||||
@@ -3368,9 +3368,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()
|
||||
@@ -3389,15 +3389,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>
|
||||
@@ -3454,9 +3460,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