Public Access
Merge branch 'claude/windows-multiselect-support-37541c'
This commit is contained in:
@@ -119,7 +119,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
// The commands are the vault's, which is the other half of putting the menu on the list rather
|
||||
// than in the item template: a menu inside the template would have the row for its data context,
|
||||
// and every one of these would silently bind to nothing.
|
||||
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
|
||||
//
|
||||
// Drawn rather than merely present, because this menu carries a second Edit… that is about the
|
||||
// ticked set rather than about the selection; with nothing ticked that one is collapsed. See
|
||||
// TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise.
|
||||
var edit = menu.Items.OfType<MenuItem>()
|
||||
.Single(item => item.IsVisible && item.Header is "Edit…");
|
||||
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
||||
|
||||
edit.Command!.Execute(null);
|
||||
@@ -260,7 +265,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
|
||||
carried.Add(DataTransferItem.Create(HostFormat, (IReadOnlyList<HostRowViewModel>)[Row(vault, "prod-db")]));
|
||||
|
||||
var onto = screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
@@ -574,6 +579,245 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
// ---- ◆ Choosing more than one card ----
|
||||
//
|
||||
// The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
|
||||
// with a pointer instead of a long press. What these hold is the half that belongs to this head: which
|
||||
// gesture means what, and the rule that keeps the list's own selection and the set from ever both being
|
||||
// about something at the same moment. See HostsScreen.axaml.cs.
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
|
||||
/// also moved the <c>ListBox</c>'s own mark would light the card it had just unticked and open the drawer
|
||||
/// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
|
||||
/// down rather than acted on as it bubbles.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
||||
/// card is in play and every command on this screen is about the same host.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var first = Row(vault, "prod-db");
|
||||
var second = Row(vault, "stage-web");
|
||||
|
||||
vault.SelectedHost = first;
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
Click(CardFor(screen, second), window, RawInputModifiers.Control);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1);
|
||||
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
|
||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
|
||||
|
||||
Click(CardFor(screen, first), window);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a plain click drops the set");
|
||||
vault.SelectedHost.ShouldBeSameAs(first, "and selects the card it landed on, as it always has");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
|
||||
/// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
|
||||
/// the grid's own, so "between" means between as the cards are laid out.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
|
||||
{
|
||||
await AddHostAsync("dev-box");
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var order = vault.VisibleHosts.ToList();
|
||||
|
||||
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
|
||||
|
||||
Click(CardFor(screen, order[0]), window);
|
||||
Click(CardFor(screen, order[2]), window, RawInputModifiers.Shift);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(3);
|
||||
order.ShouldAllBe(row => row.IsChosen);
|
||||
|
||||
Click(CardFor(screen, order[1]), window, RawInputModifiers.Shift);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2);
|
||||
order[2].IsChosen.ShouldBeFalse("the run is re-measured from the anchor, not extended");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The band, dragged out over the space around the cards.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
|
||||
/// selection or the start of a drag of that host, and the band is what the space between and below them
|
||||
/// is for. The rectangle ticks what it touches rather than what it swallows — a card is 232 pixels wide,
|
||||
/// and a band that had to contain one would tick nothing at all when drawn down a column.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The second half is the same press without the drag: a click on the empty space is how somebody who
|
||||
/// never finds Esc gets out of a selection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var first = CardFor(screen, Row(vault, "prod-db"));
|
||||
var last = CardFor(screen, Row(vault, "stage-web"));
|
||||
|
||||
var topLeft = Corner(first, window);
|
||||
var bottomRight = Corner(last, window)
|
||||
+ new Point(last.Bounds.Width, last.Bounds.Height);
|
||||
|
||||
// Below every card, so the press lands on the scroller rather than on a list item.
|
||||
var from = bottomRight.WithY(bottomRight.Y + 24);
|
||||
var to = topLeft + new Point(2, 2);
|
||||
|
||||
// The button has to be named on the moves as well as on the press: a headless move carries the
|
||||
// button state in its modifiers, and one sent without it is the pointer being let go of.
|
||||
window.MouseDown(from, MouseButton.Left);
|
||||
window.MouseMove(new Point(to.X, from.Y), RawInputModifiers.LeftMouseButton);
|
||||
window.MouseMove(to, RawInputModifiers.LeftMouseButton);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2, "the band was over both cards");
|
||||
|
||||
window.MouseUp(to, MouseButton.Left);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(2, "and letting go keeps what it was over");
|
||||
|
||||
window.MouseDown(from, MouseButton.Left);
|
||||
window.MouseUp(from, MouseButton.Left);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a press and a release with no band between them is a click");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One menu with two halves, and which half is drawn is whether anything is ticked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
|
||||
/// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
|
||||
/// must never happen is both being offered at once: a Delete… asking about the card under the pointer
|
||||
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
|
||||
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
|
||||
{
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
var ticked = Row(vault, "prod-db");
|
||||
var other = Row(vault, "stage-web");
|
||||
|
||||
Click(CardFor(screen, ticked), window, RawInputModifiers.Control);
|
||||
|
||||
RightClick(CardFor(screen, ticked), window);
|
||||
|
||||
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
||||
|
||||
menu.IsOpen.ShouldBeTrue();
|
||||
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
|
||||
|
||||
var offered = menu.Items.OfType<MenuItem>().Where(item => item.IsVisible).ToList();
|
||||
|
||||
offered.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteChosenHostsCommand));
|
||||
offered.ShouldNotContain(
|
||||
item => ReferenceEquals(item.Command, vault.DeleteHostCommand),
|
||||
"the entries about the selection are not offered beside the entries about the set");
|
||||
|
||||
menu.Close();
|
||||
|
||||
RightClick(CardFor(screen, other), window);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
|
||||
vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");
|
||||
|
||||
menu.Items.OfType<MenuItem>()
|
||||
.Where(item => item.IsVisible)
|
||||
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
|
||||
/// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
|
||||
/// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
|
||||
/// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
|
||||
{
|
||||
await AddHostAsync("dev-box");
|
||||
|
||||
vault.HostFilter = "prod";
|
||||
|
||||
await OnTheGridAsync((screen, window) =>
|
||||
{
|
||||
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
|
||||
|
||||
screen.HostGrid.Focus();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid's");
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
|
||||
|
||||
vault.ChosenHostCount.ShouldBe(1, "the one card being drawn, not the three hosts there are");
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeFalse();
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The drag carries a list because a drag that starts on a ticked card carries every ticked card, and the
|
||||
/// group cards have to answer for the whole of it. Refused only when there is nothing in it left to file:
|
||||
/// a set with one host from somewhere else in it is a real move, and offering the "no" cursor for it
|
||||
/// would be a drop the user can see the point of and cannot make.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AGroupCardTakesAWholeSetOfDraggedHosts()
|
||||
{
|
||||
await OnTheGridAsync((screen, _) =>
|
||||
{
|
||||
IReadOnlyList<HostRowViewModel> set = [Row(vault, "prod-db"), Row(vault, "stage-web")];
|
||||
|
||||
var carried = new DataTransfer();
|
||||
carried.Add(DataTransferItem.Create(HostFormat, set));
|
||||
|
||||
var onto = screen.GroupGrid
|
||||
.GetVisualDescendants()
|
||||
.OfType<ListBoxItem>()
|
||||
.Single(item => item.DataContext is HostGroupRowViewModel);
|
||||
|
||||
var over = Over(onto, carried);
|
||||
|
||||
over.DragEffects.ShouldBe(DragDropEffects.Move, "both of them can be filed there");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
||||
@@ -582,8 +826,8 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
/// 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");
|
||||
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
|
||||
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
|
||||
|
||||
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
|
||||
/// <remarks>
|
||||
@@ -610,6 +854,26 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
((Window)window).MouseUp(at, MouseButton.Right);
|
||||
}
|
||||
|
||||
/// <summary>A press and a release on one card, with whatever was being held down at the time.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, because the two say different things here: the press is where a modifier is read and
|
||||
/// where the set is dropped, and the release is where a press on a ticked card that turned out not to be
|
||||
/// a drag collapses onto it.
|
||||
/// </remarks>
|
||||
private static void Click(Visual card, Window window, RawInputModifiers held = RawInputModifiers.None)
|
||||
{
|
||||
var at = Centre(card, window);
|
||||
|
||||
window.MouseDown(at, MouseButton.Left, held);
|
||||
window.MouseUp(at, MouseButton.Left, held);
|
||||
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
}
|
||||
|
||||
private static Point Corner(Visual control, Visual window) =>
|
||||
control.TranslatePoint(default, window)
|
||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||
|
||||
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -677,6 +941,18 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
|
||||
private async Task AddHostAsync(string label)
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = $"{label}.internal";
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
|
||||
@@ -630,6 +630,115 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
|
||||
}
|
||||
|
||||
// ---- ◆ The hosts screen with a set of cards ticked ----
|
||||
//
|
||||
// Ctrl, Shift and a band put the phone's chosen-hosts set on this screen — see HostsScreen.axaml.cs — and
|
||||
// with it come one strip and three panels that had never been drawn in a window. All four sit between the
|
||||
// HOSTS heading and the grid, so every one of them shortens the grid rather than overflowing it; that is
|
||||
// the property these measure. The gestures themselves are HostGridTests'.
|
||||
|
||||
/// <remarks>
|
||||
/// The strip: a count, CLEAR, and the sentence saying the actions are on the menu — squeezed between the
|
||||
/// heading and the grid's own count on the same row. It is the one thing on this screen whose width is
|
||||
/// set by nothing but its text, so what is really being measured is that the sentence trims instead of
|
||||
/// running out over the number at the far end. Measured with the drawer open, which is the width at which
|
||||
/// it does not fit and has to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileCardsAreTicked()
|
||||
{
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
||||
|
||||
vault.IsChoosingHosts.ShouldBeTrue("the strip is only drawn while something is ticked");
|
||||
|
||||
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
|
||||
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with two cards ticked"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tallest of the three panels: the vault picker with the key question under it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A heading, a picker, a wrapping paragraph naming everything that travels and everything that does not,
|
||||
/// a tick with a second wrapping sentence beside it, and two buttons — all above the group cards and the
|
||||
/// grid, which still have to fit under it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host is given a key first, because the tick is drawn only for a move of exactly one host that has
|
||||
/// something to bring; without that this would measure the short shape and say the long one fits. The
|
||||
/// panel is opened by hand rather than through <c>MoveChosenHostsToVault</c> for the reason the group
|
||||
/// move test gives: this fixture's account holds one vault, and the command declines rather than open a
|
||||
/// picker with nothing in it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithTheChosenHostsVaultPanelOpen()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.First(choice => choice.Kind is AuthenticationKind.SshKey);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
|
||||
vault.ChosenHostVaultChoices.Add(
|
||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||
|
||||
vault.SelectedChosenHostVault = vault.ChosenHostVaultChoices[0];
|
||||
vault.IsSendingChosenHostsToAVault = true;
|
||||
|
||||
vault.HasAChosenBindingToBring
|
||||
.ShouldBeTrue("the key question is the part of this panel worth measuring");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's vault panel up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The group picker over the set, which is the same panel the phone draws and the same write a set
|
||||
/// dragged onto a group card makes. Shorter than the vault panel above and drawn in the same place, so
|
||||
/// what this adds is the picker being filled from one keychain's groups rather than from nothing.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
||||
|
||||
vault.RegroupChosenHostsCommand.Execute(null);
|
||||
|
||||
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
|
||||
vault.ChosenHostGroupChoices.Count.ShouldBeGreaterThan(1, "no group, and the three seeded ones");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's group panel up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One question for the whole set, drawn by the same card the group deletion above uses. The count is
|
||||
/// what makes it a confirmation somebody reads rather than one they press past, and the consequence line
|
||||
/// wraps — which is the part a narrower column would push out of the window.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileTheChosenHostsAreBeingDeleted()
|
||||
{
|
||||
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
|
||||
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
|
||||
|
||||
vault.DeleteChosenHostsCommand.Execute(null);
|
||||
|
||||
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's deletion question up"));
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user