Public Access
558 lines
24 KiB
C#
558 lines
24 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 board 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>
|
|
/// ◆ v5: THE GROUP-CARD HALF OF THIS SUITE IS GONE, AND IT IS NOT REPLACED HERE. The desktop used to hold a
|
|
/// wrap of group cards above the hosts — one level of the tree at a time, opened by a double-click, filed
|
|
/// into by dragging a host card onto one. All of that left with the cards: v5 draws every group as a
|
|
/// section heading instead, see <c>VaultViewModel.HostSections</c>, and a host is filed through its own
|
|
/// editor or the chosen-hosts menu's "Change group…" rather than a drag. The card-navigation surface this
|
|
/// was written against — <c>OpenGroupCommand</c>, <c>VisibleGroups</c>, <c>GroupTrail</c>,
|
|
/// <c>SelectedGroup</c>, <c>GroupCrumbViewModel</c>, and the drag command <c>MoveHostToGroupCommand</c> dragged
|
|
/// onto — is no longer reached from either head's markup; <c>GroupFilter</c> stays, since group-heading
|
|
/// commands still fall back to it. Section-level
|
|
/// coverage — flattening order, the headerless invariant, per-section collapse, filter composition, monogram
|
|
/// stability — is in <c>HostSectionsTests</c> instead, which is where a heading now belongs.
|
|
/// </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 menu's halves 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.
|
|
/// </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 board 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 OnTheBoardAsync((screen, window) =>
|
|
{
|
|
var first = Row(vault, "prod-db");
|
|
var other = Row(vault, "stage-web");
|
|
|
|
vault.SelectedHost = first;
|
|
|
|
var card = CardFor(screen, other);
|
|
RightClick(card, window);
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(other);
|
|
|
|
var menu = SectionMenuFor(card).ShouldNotBeNull();
|
|
menu.IsOpen.ShouldBeTrue();
|
|
|
|
// The commands are the vault's, which is the other half of putting the menu on each section's
|
|
// own list rather than in the card's 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.IsVisible && 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>
|
|
/// The space around the cards and below them is part of <c>Board</c> rather than of any one section's
|
|
/// list, 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. Raised on <c>Board</c> itself, which is where the code-behind's tunnelled
|
|
/// handler is attached; see <c>HostsScreen.axaml.cs</c>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
|
{
|
|
await OnTheBoardAsync((screen, _) =>
|
|
{
|
|
var selected = Row(vault, "prod-db");
|
|
vault.SelectedHost = selected;
|
|
|
|
screen.Board.RaiseEvent(new ContextRequestedEventArgs
|
|
{
|
|
RoutedEvent = Control.ContextRequestedEvent,
|
|
Source = screen.Board,
|
|
});
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
|
|
|
screen.GetVisualDescendants().OfType<ListBox>()
|
|
.Where(list => list.Classes.Contains("sectioncards"))
|
|
.Select(list => list.ContextMenu)
|
|
.OfType<ContextMenu>()
|
|
.ShouldAllBe(menu => !menu.IsOpen, "no section's menu opened over the empty space");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Choosing a host costs nothing, and the pencil on its card is what spends the 320 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>#Board</c> path that resolves to nothing compiles, draws, and does nothing when pressed. See the
|
|
/// remark on that idiom at the top of <c>HostsScreen.axaml</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
|
|
{
|
|
await OnTheBoardAsync((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.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
|
|
{
|
|
await OnTheBoardAsync((_, _) =>
|
|
{
|
|
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");
|
|
});
|
|
}
|
|
|
|
// ---- ◆ 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 a section 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 a section's own <c>ListBox</c> 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 OnTheBoardAsync((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");
|
|
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were 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 board's own — <c>VaultViewModel.HostBoardOrder</c> — so "between" means between as the cards are
|
|
/// laid out, across sections if the run crosses one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
|
|
{
|
|
await AddHostAsync("dev-box");
|
|
|
|
await OnTheBoardAsync((screen, window) =>
|
|
{
|
|
var order = vault.HostBoardOrder.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.
|
|
/// </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 OnTheBoardAsync((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 right click outside the set is what drops it, so
|
|
/// the two are never both meaningful.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
|
|
{
|
|
await OnTheBoardAsync((screen, window) =>
|
|
{
|
|
var ticked = Row(vault, "prod-db");
|
|
var other = Row(vault, "stage-web");
|
|
|
|
var tickedCard = CardFor(screen, ticked);
|
|
|
|
Click(tickedCard, window, RawInputModifiers.Control);
|
|
|
|
RightClick(tickedCard, window);
|
|
|
|
var menu = SectionMenuFor(tickedCard).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();
|
|
|
|
var otherCard = CardFor(screen, other);
|
|
RightClick(otherCard, 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");
|
|
|
|
SectionMenuFor(otherCard).ShouldNotBeNull().Items.OfType<MenuItem>()
|
|
.Where(item => item.IsVisible)
|
|
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Ctrl+A is every card <em>on the board</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
|
|
/// <c>Board</c> 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 OnTheBoardAsync((screen, window) =>
|
|
{
|
|
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
|
|
|
|
screen.Board.Focus();
|
|
Dispatcher.UIThread.RunJobs();
|
|
|
|
screen.Board.IsFocused.ShouldBeTrue("the keys are the board'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();
|
|
});
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private static void RightClick(Visual card, Visual window)
|
|
{
|
|
var at = Centre(card, window);
|
|
|
|
((Window)window).MouseDown(at, MouseButton.Right);
|
|
((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 OnTheBoardAsync(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);
|
|
|
|
/// <summary>The card for a host, out of whichever section's own list is drawing it.</summary>
|
|
private static ListBoxItem CardFor(Visual screen, HostRowViewModel row) =>
|
|
screen.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.First(item => ReferenceEquals(item.DataContext, row));
|
|
|
|
/// <summary>The <c>ContextMenu</c> of the section list a card belongs to.</summary>
|
|
/// <remarks>
|
|
/// Every section draws its own <c>ListBox</c> — see the remarks on <c>HostSectionViewModel</c> — so
|
|
/// there is no longer one menu for the whole board; this is what "the menu" now means for a given card.
|
|
/// </remarks>
|
|
private static ContextMenu? SectionMenuFor(ListBoxItem card) =>
|
|
card.FindAncestorOfType<ListBox>()?.ContextMenu;
|
|
|
|
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");
|
|
|
|
/// <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 board and a selection to move off. Neither host
|
|
/// is filed under the group — it stays in the seed only because several of the suites in this project
|
|
/// still ask for one to exist, empty though it is here.
|
|
/// </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);
|
|
}
|
|
}
|