Public Access
Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user