Public Access
707 lines
31 KiB
C#
707 lines
31 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 the commands
|
|
/// on both of that screen's menus read the vault's 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 — and the group cards have the same menu with a fallback behind it that makes getting it wrong
|
|
/// quieter still.
|
|
/// </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>
|
|
/// The same rule on the cards above, where getting it wrong is quieter and worse.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
|
|
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected — the
|
|
/// right answer for the pair of buttons beside the heading, and the wrong one for a menu that opened on a
|
|
/// card, which would then offer to delete a group the pointer is nowhere near.
|
|
/// </para>
|
|
/// <para>
|
|
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
|
|
/// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
|
|
/// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickSelectsTheGroupUnderThePointer()
|
|
{
|
|
await AddGroupAsync("staging");
|
|
|
|
await OnTheGridAsync((screen, window) =>
|
|
{
|
|
var first = GroupRow(vault, "production");
|
|
var other = GroupRow(vault, "staging");
|
|
|
|
vault.SelectedGroup = first;
|
|
|
|
RightClick(CardFor(screen, other), window);
|
|
|
|
vault.SelectedGroup.ShouldBeSameAs(other);
|
|
|
|
var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
|
|
menu.IsOpen.ShouldBeTrue();
|
|
|
|
var items = menu.Items.OfType<MenuItem>().ToList();
|
|
|
|
var open = items.Single(item => item.Header is "Open");
|
|
open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
|
|
open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");
|
|
|
|
var edit = items.Single(item => item.Header is "Edit…");
|
|
edit.Command.ShouldBeSameAs(vault.EditGroupCommand);
|
|
|
|
edit.Command!.Execute(null);
|
|
|
|
vault.IsEditingGroup.ShouldBeTrue();
|
|
vault.GroupEditorLabel.ShouldBe(
|
|
other.Label, "the card that was right-clicked, not the one selected before");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The space around the group cards, where a menu would be at its most misleading: nothing is under the
|
|
/// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
|
|
/// trail ends with — which, once it is open, is not a card on screen at all.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var selected = GroupRow(vault, "production");
|
|
vault.SelectedGroup = selected;
|
|
|
|
screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
|
|
{
|
|
RoutedEvent = Control.ContextRequestedEvent,
|
|
Source = screen.GroupGrid,
|
|
});
|
|
|
|
vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
|
screen.GroupGrid.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);
|
|
|
|
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
|
|
private static ListBoxItem CardFor(Visual screen, object row) =>
|
|
screen.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.First(item => ReferenceEquals(item.DataContext, row));
|
|
|
|
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
|
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
|
|
|
/// <remarks>
|
|
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
|
|
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
|
|
/// </remarks>
|
|
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
|
|
vault.VisibleGroups.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);
|
|
}
|
|
}
|