Public Access
The hosts screen draws two grids, one above the other, and each is a ListBox with a selection of its own. Nothing joined them, so a group card and a host card could be lit at the same moment — two chosen things, under two pairs of buttons of which only one would act on whichever the eye had settled on. Both grids mark a selection the same way, so there was nothing on screen to say which of the two the next press belonged to. They share one mark now. Selecting a host clears the group and selecting a group clears the host, and that second one takes the detail pane down with it: a pane about one machine cannot go on standing beside a marked group, because nothing on it would be about what is selected. Losing a selection deliberately clears nothing. A null arrives whenever either list is rebuilt — every keystroke in the filter box and every background sync — and treating that as somebody deselecting would take the mark off a group card because a search emptied the grid beneath it. The reload needed the same guard for the same reason. It falls back to the first host when nothing is selected, which is what puts a target under CONNECT on a fresh unlock; with one mark between the two grids that fallback would have unselected a group nobody had touched, once a minute. It is skipped while a group holds the selection, and it moved into a method of its own because the comment saying why pushed ReloadHostsAsync past sixty lines. One consequence needed handling rather than accepting. The phone's only route into the group editor is a button on a heading in the host list, and it worked by selecting the group first — which under this rule takes the highlight off the machine somebody was about to connect to, on a screen that draws no group cards to say where it has gone. EditGroup takes the group as an argument now: the heading passes its own row, and the desktop's button beside the cards passes nothing and still means "the card that is selected". What the tests hold is the half that lives in the controls. Clearing the property has to reach the list that is drawing the card, and a selection nulled in the view model while the card stays highlighted is the exact failure this is about — so the rule is driven on the real screen, in both directions, against the ListBoxes' own SelectedItem. The desktop's EDIT is pressed through its binding for the same kind of reason: a command refusing the button's empty parameter would be a button that never fires, and nothing about the markup would say so.
622 lines
27 KiB
C#
622 lines
27 KiB
C#
using Avalonia;
|
|
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;
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// How the grid of host cards answers a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
|
|
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
|
|
/// <c>HostsScreen.axaml.cs</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
|
|
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
|
|
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because all three of
|
|
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
|
|
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
|
|
/// suite.
|
|
/// </para>
|
|
/// <para>
|
|
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
|
|
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
|
|
/// hosts and groups.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class HostGridTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
private const string ServerUrl = "https://dodossh.example";
|
|
|
|
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
|
private static readonly Argon2Profile CheapProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
|
|
private readonly FakeAccountServer server = new();
|
|
private readonly StubKeyBinding keyBinding = new();
|
|
private readonly VaultKnownHostStore knownHosts = new();
|
|
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private VaultSession session = null!;
|
|
private VaultViewModel vault = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}");
|
|
await caches.MigrateAsync(Token);
|
|
|
|
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
|
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
|
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
session = outcome.Session!;
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
await knownHosts.OpenAsync(session, Token);
|
|
|
|
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
|
|
|
await SeedAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
knownHosts.Close();
|
|
await workspace.DisposeAsync();
|
|
await vault.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The rule the menu depends on. Without it the three commands would act on whatever was selected
|
|
/// before, which for Delete is a question asked about one machine and answered about another.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickSelectsTheHostUnderThePointer()
|
|
{
|
|
await OnTheGridAsync((screen, window) =>
|
|
{
|
|
var first = Row(vault, "prod-db");
|
|
var other = Row(vault, "stage-web");
|
|
|
|
vault.SelectedHost = first;
|
|
|
|
RightClick(CardFor(screen, other), window);
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(other);
|
|
|
|
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
|
menu.IsOpen.ShouldBeTrue();
|
|
|
|
// 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…");
|
|
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
|
|
|
edit.Command!.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue();
|
|
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <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 ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var selected = Row(vault, "prod-db");
|
|
vault.SelectedHost = selected;
|
|
|
|
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>
|
|
/// <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 SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
|
|
{
|
|
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
|
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
|
|
|
|
// Re-found after the move, because the reload it ends with replaces every row in the list.
|
|
var production = vault.VisibleGroups.Single();
|
|
|
|
vault.SelectedGroup = production;
|
|
|
|
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
|
|
vault.VisibleHosts.Select(row => row.Label)
|
|
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
|
|
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 group that is open");
|
|
|
|
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);
|
|
|
|
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");
|
|
|
|
// 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.Select(row => row.Label)
|
|
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
|
|
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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The two grids share one selection, so at most one card on the screen is ever lit.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// They are two <c>ListBox</c>es, each holding a selection of its own and each drawing it the same way.
|
|
/// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of
|
|
/// which only one acts on whichever card the eye has settled on. The vault is what joins them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Driven on the screen rather than on the view model alone, because half of the rule lives in the
|
|
/// controls: clearing the property has to reach the list that is drawing the card, and a selection
|
|
/// nulled in the view model while the card stays highlighted is the exact failure this is about.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheHostAndGroupGridsShareOneSelection()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var host = Row(vault, "stage-web");
|
|
|
|
vault.OpenHostPaneCommand.Execute(host);
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
vault.SelectedGroup = vault.VisibleGroups.Single();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else");
|
|
vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told");
|
|
screen.HostGrid.SelectedItem.ShouldBeNull();
|
|
CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark");
|
|
vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group");
|
|
|
|
vault.SelectedHost = host;
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
|
|
screen.GroupGrid.SelectedItem.ShouldBeNull();
|
|
GroupCard(screen).IsSelected.ShouldBeFalse();
|
|
vault.ShowsGroupActions.ShouldBeFalse("so the group's own two buttons have nothing to act on");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// EDIT takes the group as an argument now, so that the phone can open the editor on a heading without
|
|
/// selecting a group and losing the host selection to it — see <c>VaultViewModel.EditGroup</c>. The
|
|
/// button beside the cards passes nothing and means "the card that is selected", which is the half of
|
|
/// that change that would fail silently: a command refusing a null parameter is a button that never
|
|
/// fires, and nothing about the markup would say so.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheGroupsEditButtonStillActsOnTheSelectedCard()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
vault.SelectedGroup = vault.VisibleGroups.Single();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
var edit = screen.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.Single(button => ReferenceEquals(button.Command, vault.EditGroupCommand));
|
|
|
|
edit.IsEffectivelyEnabled.ShouldBeTrue("the command has to accept the button's empty parameter");
|
|
|
|
edit.Command.ShouldNotBeNull().Execute(edit.CommandParameter);
|
|
|
|
vault.IsEditingGroup.ShouldBeTrue();
|
|
vault.GroupEditorLabel.ShouldBe("production", "the card that was selected");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
|
|
/// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
|
|
/// between the two grids that fallback would quietly unselect a group card every time a sync landed.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
|
|
{
|
|
var production = vault.VisibleGroups.Single();
|
|
|
|
vault.SelectedGroup = production;
|
|
vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");
|
|
|
|
await vault.LoadAsync(Token);
|
|
|
|
vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
|
|
vault.SelectedGroup
|
|
.ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
|
|
.EntityId.ShouldBe(production.EntityId);
|
|
}
|
|
|
|
/// <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"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The two halves are one rule and are asserted together, because either alone would pass on a broken
|
|
/// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the
|
|
/// second. What is being held is that opening is <em>deliberate</em>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Driven through the card's own button rather than by executing the command, since the thing most
|
|
/// likely to rot is the binding that reaches out of the item template to the vault's command — a
|
|
/// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var host = Row(vault, "stage-web");
|
|
|
|
vault.SelectedHost = host;
|
|
|
|
vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane");
|
|
|
|
// The button is hidden until the pointer is on the card, so a click cannot be synthesised at a
|
|
// point: what a headless run can reach is the control and the command behind it.
|
|
var pencil = CardFor(screen, host)
|
|
.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.Classes.Contains("rowedit"));
|
|
|
|
pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
|
|
pencil.Command.Execute(pencil.CommandParameter);
|
|
|
|
vault.IsDrawerOpen.ShouldBeTrue();
|
|
vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
|
|
vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
|
|
/// selection that goes away entirely has to take it with it. Without that the flag would survive a
|
|
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
|
|
/// which is the behaviour the pencil exists to remove.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
|
|
{
|
|
await OnTheGridAsync((_, _) =>
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
|
|
vault.IsDrawerOpen.ShouldBeTrue();
|
|
|
|
vault.SelectedHost = null;
|
|
vault.IsDrawerOpen.ShouldBeFalse();
|
|
|
|
vault.SelectedHost = Row(vault, "stage-web");
|
|
vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
|
|
});
|
|
}
|
|
|
|
// ---- 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);
|
|
|
|
((Window)window).MouseDown(at, MouseButton.Right);
|
|
((Window)window).MouseUp(at, MouseButton.Right);
|
|
}
|
|
|
|
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new HostsScreen { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(screen, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
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>()
|
|
.First(item => ReferenceEquals(item.DataContext, host));
|
|
|
|
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
|
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
|
|
|
private static Point Centre(Visual control, Visual window) =>
|
|
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()
|
|
{
|
|
foreach (var label in new[] { "prod-db", "stage-web" })
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = label;
|
|
vault.EditorHostname = $"{label}.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
vault.GroupEditorLabel = "production";
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
|
|
await vault.LoadAsync(Token);
|
|
}
|
|
}
|