Public Access
Flatten the hosts screen into one board of sections
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -18,26 +18,36 @@ using NSubstitute;
|
|||||||
namespace DodoSSH.Client.App.Layout.Tests;
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How the grid of host cards answers a pointer.
|
/// How the board of host cards answers a pointer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
|
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on <see cref="HostsScreen"/>
|
||||||
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
|
/// now, and so is every handler that was wired to them. See <c>HostsScreen.axaml.cs</c>.
|
||||||
/// <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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
|
/// 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
|
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by measuring:
|
||||||
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because the commands
|
/// a right click has to move the selection <em>before</em> the menu opens, because the commands on both of
|
||||||
/// on both of that screen's menus read the vault's selection. A menu that quietly acted on whichever host
|
/// that menu's halves read the vault's selection. A menu that quietly acted on whichever host happened to be
|
||||||
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
|
/// selected would delete the wrong machine, which is the version of this mistake worth a suite.
|
||||||
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
|
|
||||||
/// quieter still.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
|
/// 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
|
/// one: compiled bindings resolve against the declared type, and the board is built out of the vault's own
|
||||||
/// hosts and groups.
|
/// hosts and groups.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -102,27 +112,24 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARightClickSelectsTheHostUnderThePointer()
|
public async Task ARightClickSelectsTheHostUnderThePointer()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var first = Row(vault, "prod-db");
|
var first = Row(vault, "prod-db");
|
||||||
var other = Row(vault, "stage-web");
|
var other = Row(vault, "stage-web");
|
||||||
|
|
||||||
vault.SelectedHost = first;
|
vault.SelectedHost = first;
|
||||||
|
|
||||||
RightClick(CardFor(screen, other), window);
|
var card = CardFor(screen, other);
|
||||||
|
RightClick(card, window);
|
||||||
|
|
||||||
vault.SelectedHost.ShouldBeSameAs(other);
|
vault.SelectedHost.ShouldBeSameAs(other);
|
||||||
|
|
||||||
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
var menu = SectionMenuFor(card).ShouldNotBeNull();
|
||||||
menu.IsOpen.ShouldBeTrue();
|
menu.IsOpen.ShouldBeTrue();
|
||||||
|
|
||||||
// The commands are the vault's, which is the other half of putting the menu on the list rather
|
// The commands are the vault's, which is the other half of putting the menu on each section's
|
||||||
// than in the item template: a menu inside the template would have the row for its data context,
|
// own list rather than in the card's item template: a menu inside the template would have the
|
||||||
// and every one of these would silently bind to nothing.
|
// row for its data context, and every one of these would silently bind to nothing.
|
||||||
//
|
|
||||||
// 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>()
|
var edit = menu.Items.OfType<MenuItem>()
|
||||||
.Single(item => item.IsVisible && item.Header is "Edit…");
|
.Single(item => item.IsVisible && item.Header is "Edit…");
|
||||||
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
||||||
@@ -135,388 +142,38 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// The space around the cards and below them is part of <c>Board</c> rather than of any one section's
|
||||||
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
|
/// list, and a menu offering Connect, Edit and Delete over it would be three entries acting on whichever
|
||||||
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
|
/// machine happened to be selected — which is the whole mistake this handler exists to prevent, reached
|
||||||
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
|
/// by clicking nothing at all. Raised on <c>Board</c> itself, which is where the code-behind's tunnelled
|
||||||
/// </para>
|
/// handler is attached; see <c>HostsScreen.axaml.cs</c>.
|
||||||
/// <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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, _) =>
|
await OnTheBoardAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
var selected = Row(vault, "prod-db");
|
var selected = Row(vault, "prod-db");
|
||||||
vault.SelectedHost = selected;
|
vault.SelectedHost = selected;
|
||||||
|
|
||||||
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
|
screen.Board.RaiseEvent(new ContextRequestedEventArgs
|
||||||
{
|
{
|
||||||
RoutedEvent = Control.ContextRequestedEvent,
|
RoutedEvent = Control.ContextRequestedEvent,
|
||||||
Source = screen.HostGrid,
|
Source = screen.Board,
|
||||||
});
|
});
|
||||||
|
|
||||||
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
||||||
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
|
||||||
|
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>
|
/// <summary>
|
||||||
/// The same rule on the cards above, where getting it wrong is quieter and worse.
|
/// Choosing a host costs nothing, and the pencil on its card is what spends the 320 pixels.
|
||||||
/// </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, and
|
|
||||||
/// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
|
|
||||||
/// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
|
|
||||||
/// </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, (IReadOnlyList<HostRowViewModel>)[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 marks 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 SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
|
|
||||||
{
|
|
||||||
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 a group command with no argument acts on");
|
|
||||||
|
|
||||||
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 an unaimed command falls 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");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
|
|
||||||
/// the whole of both now — the menu came second and did the same job better, since it acts on the card
|
|
||||||
/// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
|
|
||||||
/// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
|
|
||||||
/// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
|
|
||||||
{
|
|
||||||
await OnTheGridAsync((screen, _) =>
|
|
||||||
{
|
|
||||||
vault.SelectedGroup = vault.VisibleGroups.Single();
|
|
||||||
Dispatcher.UIThread.RunJobs();
|
|
||||||
|
|
||||||
screen.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|
|
||||||
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
|
|
||||||
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -527,13 +184,14 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Driven through the card's own button rather than by executing the command, since the thing most
|
/// 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
|
/// 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.
|
/// <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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
|
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, _) =>
|
await OnTheBoardAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
var host = Row(vault, "stage-web");
|
var host = Row(vault, "stage-web");
|
||||||
|
|
||||||
@@ -559,14 +217,12 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
|
/// 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
|
/// selection that goes away entirely has to take it with it.
|
||||||
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
|
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((_, _) =>
|
await OnTheBoardAsync((_, _) =>
|
||||||
{
|
{
|
||||||
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
|
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
|
||||||
vault.IsDrawerOpen.ShouldBeTrue();
|
vault.IsDrawerOpen.ShouldBeTrue();
|
||||||
@@ -583,15 +239,15 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
//
|
//
|
||||||
// The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
|
// 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
|
// 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
|
// gesture means what, and the rule that keeps a section list's own selection and the set from ever both
|
||||||
// about something at the same moment. See HostsScreen.axaml.cs.
|
// being about something at the same moment. See HostsScreen.axaml.cs.
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
|
/// 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
|
/// also moved a section's own <c>ListBox</c> mark would light the card it had just unticked and open the
|
||||||
/// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
|
/// drawer on a machine somebody is removing from a set. Stopping that is why the press is handled on the
|
||||||
/// down rather than acted on as it bubbles.
|
/// way down rather than acted on as it bubbles.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
|
||||||
@@ -601,7 +257,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var first = Row(vault, "prod-db");
|
var first = Row(vault, "prod-db");
|
||||||
var second = Row(vault, "stage-web");
|
var second = Row(vault, "stage-web");
|
||||||
@@ -614,7 +270,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
vault.ChosenHostCount.ShouldBe(1);
|
vault.ChosenHostCount.ShouldBe(1);
|
||||||
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
|
||||||
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
|
||||||
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");
|
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
|
||||||
|
|
||||||
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
Click(CardFor(screen, first), window, RawInputModifiers.Control);
|
||||||
vault.ChosenHostCount.ShouldBe(2);
|
vault.ChosenHostCount.ShouldBe(2);
|
||||||
@@ -632,16 +288,17 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
|
/// 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
|
/// 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.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
|
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
|
||||||
{
|
{
|
||||||
await AddHostAsync("dev-box");
|
await AddHostAsync("dev-box");
|
||||||
|
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var order = vault.VisibleHosts.ToList();
|
var order = vault.HostBoardOrder.ToList();
|
||||||
|
|
||||||
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
|
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
|
||||||
|
|
||||||
@@ -665,8 +322,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
|
/// 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
|
/// 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,
|
/// is for. The rectangle ticks what it touches rather than what it swallows.
|
||||||
/// and a band that had to contain one would tick nothing at all when drawn down a column.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The second half is the same press without the drag: a click on the empty space is how somebody who
|
/// The second half is the same press without the drag: a click on the empty space is how somebody who
|
||||||
@@ -676,7 +332,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
|
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var first = CardFor(screen, Row(vault, "prod-db"));
|
var first = CardFor(screen, Row(vault, "prod-db"));
|
||||||
var last = CardFor(screen, Row(vault, "stage-web"));
|
var last = CardFor(screen, Row(vault, "stage-web"));
|
||||||
@@ -718,23 +374,24 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
|
/// 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
|
/// 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
|
/// must never happen is both being offered at once. A right click outside the set is what drops it, so
|
||||||
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
|
/// the two are never both meaningful.
|
||||||
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
|
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
|
||||||
{
|
{
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var ticked = Row(vault, "prod-db");
|
var ticked = Row(vault, "prod-db");
|
||||||
var other = Row(vault, "stage-web");
|
var other = Row(vault, "stage-web");
|
||||||
|
|
||||||
Click(CardFor(screen, ticked), window, RawInputModifiers.Control);
|
var tickedCard = CardFor(screen, ticked);
|
||||||
|
|
||||||
RightClick(CardFor(screen, ticked), window);
|
Click(tickedCard, window, RawInputModifiers.Control);
|
||||||
|
|
||||||
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
RightClick(tickedCard, window);
|
||||||
|
|
||||||
|
var menu = SectionMenuFor(tickedCard).ShouldNotBeNull();
|
||||||
|
|
||||||
menu.IsOpen.ShouldBeTrue();
|
menu.IsOpen.ShouldBeTrue();
|
||||||
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
|
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
|
||||||
@@ -748,22 +405,23 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
|
|
||||||
menu.Close();
|
menu.Close();
|
||||||
|
|
||||||
RightClick(CardFor(screen, other), window);
|
var otherCard = CardFor(screen, other);
|
||||||
|
RightClick(otherCard, window);
|
||||||
|
|
||||||
vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
|
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");
|
vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");
|
||||||
|
|
||||||
menu.Items.OfType<MenuItem>()
|
SectionMenuFor(otherCard).ShouldNotBeNull().Items.OfType<MenuItem>()
|
||||||
.Where(item => item.IsVisible)
|
.Where(item => item.IsVisible)
|
||||||
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
|
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
|
/// Ctrl+A is every card <em>on the board</em> and not every host in the keychain, which is the difference
|
||||||
/// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
|
/// that matters the moment there is something in the find box: a shortcut that quietly ticked the
|
||||||
/// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
|
/// machines it is not showing would be the worst possible input to Delete. Esc is the way back out, and
|
||||||
/// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
|
/// <c>Board</c> is where both are handled — Ctrl+A in the find box above has to go on selecting text.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
|
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
|
||||||
@@ -772,14 +430,14 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
|
|
||||||
vault.HostFilter = "prod";
|
vault.HostFilter = "prod";
|
||||||
|
|
||||||
await OnTheGridAsync((screen, window) =>
|
await OnTheBoardAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
|
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
|
||||||
|
|
||||||
screen.HostGrid.Focus();
|
screen.Board.Focus();
|
||||||
Dispatcher.UIThread.RunJobs();
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid's");
|
screen.Board.IsFocused.ShouldBeTrue("the keys are the board's");
|
||||||
|
|
||||||
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
|
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
|
||||||
|
|
||||||
@@ -791,64 +449,11 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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 ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
private static void RightClick(Visual card, Visual window)
|
||||||
/// <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<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>
|
|
||||||
/// 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);
|
var at = Centre(card, window);
|
||||||
|
|
||||||
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).MouseDown(at, MouseButton.Right);
|
||||||
((Window)window).MouseUp(at, MouseButton.Right);
|
((Window)window).MouseUp(at, MouseButton.Right);
|
||||||
@@ -874,7 +479,7 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
control.TranslatePoint(default, window)
|
control.TranslatePoint(default, window)
|
||||||
?? throw new InvalidOperationException("the control is not in this window's tree");
|
?? throw new InvalidOperationException("the control is not in this window's tree");
|
||||||
|
|
||||||
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
private Task OnTheBoardAsync(Action<HostsScreen, Window> body) =>
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
@@ -894,53 +499,27 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
},
|
},
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
private static ListBoxItem GroupCard(HostsScreen screen) =>
|
/// <summary>The card for a host, out of whichever section's own list is drawing it.</summary>
|
||||||
screen.GroupGrid
|
private static ListBoxItem CardFor(Visual screen, HostRowViewModel row) =>
|
||||||
.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()
|
screen.GetVisualDescendants()
|
||||||
.OfType<ListBoxItem>()
|
.OfType<ListBoxItem>()
|
||||||
.First(item => ReferenceEquals(item.DataContext, row));
|
.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) =>
|
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
||||||
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
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) =>
|
private static Point Centre(Visual control, Visual window) =>
|
||||||
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), 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");
|
?? 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
|
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
|
||||||
private async Task AddHostAsync(string label)
|
private async Task AddHostAsync(string label)
|
||||||
{
|
{
|
||||||
@@ -953,7 +532,11 @@ public sealed class HostGridTests : IAsyncLifetime
|
|||||||
await vault.LoadAsync(Token);
|
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>
|
/// <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()
|
private async Task SeedAsync()
|
||||||
{
|
{
|
||||||
foreach (var label in new[] { "prod-db", "stage-web" })
|
foreach (var label in new[] { "prod-db", "stage-web" })
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
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>
|
||||||
|
/// <see cref="VaultViewModel.HostSections"/>: the desktop board's own flattening, one section per group.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Split out of <see cref="HostGridTests"/> rather than folded into it, because none of what is here needs a
|
||||||
|
/// realized <c>HostsScreen</c> — it is <see cref="VaultViewModel"/> alone, the same surface
|
||||||
|
/// <c>VaultViewModel.RebuildHostSections</c>, <c>FlattenIntoSections</c> and the monogram properties on
|
||||||
|
/// <see cref="HostRowViewModel"/> already carry doc comments for. This is where those comments are held to
|
||||||
|
/// account.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason every other suite in this
|
||||||
|
/// project uses one: compiled bindings resolve against the declared type, and a stand-in would still have to
|
||||||
|
/// be it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HostSectionsTests : 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($"host-sections-{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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
knownHosts.Close();
|
||||||
|
await workspace.DisposeAsync();
|
||||||
|
await vault.DisposeAsync();
|
||||||
|
caches.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The invariant <c>HostsScreen.axaml</c> depends on for a groupless keychain to look exactly as it did
|
||||||
|
/// before groups existed: one section, and its <c>Header</c> is null rather than a heading with nothing
|
||||||
|
/// worth saying. See <c>HostSectionViewModel.HasHeader</c> and the XAML's own <c>IsVisible</c> on it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AGrouplessKeychainDrawsOneHeaderlessSection()
|
||||||
|
{
|
||||||
|
await AddHostAsync("prod-db");
|
||||||
|
await AddHostAsync("stage-web");
|
||||||
|
|
||||||
|
vault.HostSections.ShouldHaveSingleItem();
|
||||||
|
vault.HostSections[0].HasHeader.ShouldBeFalse();
|
||||||
|
vault.HostSections[0].Hosts.Select(row => row.Label)
|
||||||
|
.ShouldBe(["prod-db", "stage-web"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every group is a section, in label order, "No group" first — the flattening the phone's
|
||||||
|
/// <c>SidebarRows</c> has always drawn, adopted by the desktop board in v5.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The three groups are made out of label order on purpose — "zeta" before "alpha" before "mid" — so a
|
||||||
|
/// pass that happened to preserve creation order rather than sorting would still be caught.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task SectionsAreInLabelOrderWithNoGroupFirst()
|
||||||
|
{
|
||||||
|
await AddGroupAsync("zeta");
|
||||||
|
await AddGroupAsync("alpha");
|
||||||
|
await AddGroupAsync("mid");
|
||||||
|
|
||||||
|
await AddHostAsync("loose");
|
||||||
|
await AddHostInGroupAsync("z-host", "zeta");
|
||||||
|
await AddHostInGroupAsync("a-host", "alpha");
|
||||||
|
await AddHostInGroupAsync("m-host", "mid");
|
||||||
|
|
||||||
|
vault.HostSections.Select(section => section.Header?.Label ?? "No group")
|
||||||
|
.ShouldBe(["No group", "alpha", "mid", "zeta"]);
|
||||||
|
|
||||||
|
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "alpha", StringComparison.Ordinal))
|
||||||
|
.Hosts.Select(row => row.Label).ShouldBe(["a-host"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An empty group still gets a heading; a filter that empties a group's contents does not remove it, but
|
||||||
|
/// the ungrouped heading disappears entirely once nothing is left under it.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AnEmptyNamedGroupKeepsItsHeadingAndAnEmptyUngroupedHeadingDropsAway()
|
||||||
|
{
|
||||||
|
await AddGroupAsync("empty-shelf");
|
||||||
|
await AddHostInGroupAsync("only-host", "empty-shelf");
|
||||||
|
|
||||||
|
vault.HostSections.Select(section => section.Header?.Label)
|
||||||
|
.ShouldNotContain("No group", "nothing is ungrouped, so that heading is not drawn at all");
|
||||||
|
|
||||||
|
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "empty-shelf", StringComparison.Ordinal))
|
||||||
|
.Hosts.Select(row => row.Label).ShouldBe(["only-host"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Collapsing a section keeps its heading — the count on it is still real — and empties its own card
|
||||||
|
/// list; expanding it again restores the cards. Both go through <c>ToggleGroupCommand</c>, the same
|
||||||
|
/// command the heading's chevron button is bound to.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TogglingASectionEmptiesItsCardsWithoutDroppingItsHeading()
|
||||||
|
{
|
||||||
|
await AddGroupAsync("production");
|
||||||
|
await AddHostInGroupAsync("prod-db", "production");
|
||||||
|
await AddHostInGroupAsync("prod-web", "production");
|
||||||
|
|
||||||
|
var header = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal)).Header!;
|
||||||
|
|
||||||
|
header.IsExpanded.ShouldBeTrue("nothing has folded it yet");
|
||||||
|
|
||||||
|
vault.ToggleGroupCommand.Execute(header);
|
||||||
|
|
||||||
|
var collapsed = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal));
|
||||||
|
collapsed.Header!.IsExpanded.ShouldBeFalse();
|
||||||
|
collapsed.Header.Count.ShouldBe(2, "the heading still says how many are under it");
|
||||||
|
collapsed.Hosts.ShouldBeEmpty("but the card list under it is folded away");
|
||||||
|
|
||||||
|
vault.ToggleGroupCommand.Execute(collapsed.Header);
|
||||||
|
|
||||||
|
vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal))
|
||||||
|
.Hosts.Select(row => row.Label).ShouldBe(["prod-db", "prod-web"], ignoreOrder: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Collapse all folds every section at once and Expand all — the same command, its label swapped by
|
||||||
|
/// <c>CollapseAllLabel</c> — brings every one of them back. See the remark on that property for why it
|
||||||
|
/// reads "collapse" until every section already is one.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task CollapseAllFoldsEverySectionAndExpandAllRestoresThem()
|
||||||
|
{
|
||||||
|
await AddGroupAsync("alpha");
|
||||||
|
await AddGroupAsync("beta");
|
||||||
|
await AddHostInGroupAsync("a-host", "alpha");
|
||||||
|
await AddHostInGroupAsync("b-host", "beta");
|
||||||
|
await AddHostAsync("loose");
|
||||||
|
|
||||||
|
vault.CollapseAllLabel.ShouldBe("Collapse all", "something is still open, so this is the fold action");
|
||||||
|
|
||||||
|
vault.ToggleAllGroupsCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.HostSections.SelectMany(section => section.Hosts).ShouldBeEmpty("every section folded at once");
|
||||||
|
vault.CollapseAllLabel.ShouldBe("Expand all");
|
||||||
|
|
||||||
|
vault.ToggleAllGroupsCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.HostSections.SelectMany(section => section.Hosts).Select(row => row.Label)
|
||||||
|
.ShouldBe(["loose", "a-host", "b-host"], ignoreOrder: true);
|
||||||
|
vault.CollapseAllLabel.ShouldBe("Collapse all");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Group ▾, Tag ▾ and the find box all narrow the same board at once, and a host has to clear every one
|
||||||
|
/// of them that is active — the toolbar's two flyouts are an AND with each other and with the box, even
|
||||||
|
/// though a single flyout's own ticks are an OR among themselves.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupFilterTagFilterAndFindComposeAsAnIntersection()
|
||||||
|
{
|
||||||
|
await AddGroupAsync("alpha");
|
||||||
|
await AddGroupAsync("beta");
|
||||||
|
|
||||||
|
await AddHostInGroupWithTagAsync("a-prod", "alpha", "prod");
|
||||||
|
await AddHostInGroupWithTagAsync("a-dev", "alpha", "dev");
|
||||||
|
await AddHostInGroupWithTagAsync("b-prod", "beta", "prod");
|
||||||
|
|
||||||
|
var alphaChoice = vault.GroupFilterChoices.Single(choice => choice.Label == "alpha");
|
||||||
|
vault.ToggleGroupFilterCommand.Execute(alphaChoice);
|
||||||
|
|
||||||
|
vault.HostBoardOrder.Select(row => row.Label)
|
||||||
|
.ShouldBe(["a-prod", "a-dev"], ignoreOrder: true, "narrowed to alpha, both its hosts");
|
||||||
|
|
||||||
|
var prodChoice = vault.TagFilterChoices.Single(choice => choice.Label == "prod");
|
||||||
|
vault.ToggleTagFilterCommand.Execute(prodChoice);
|
||||||
|
|
||||||
|
vault.HostBoardOrder.Select(row => row.Label)
|
||||||
|
.ShouldBe(["a-prod"], "alpha AND prod, not either alone — b-prod fails the group half");
|
||||||
|
|
||||||
|
vault.HostFilter = "a-dev";
|
||||||
|
|
||||||
|
vault.HostBoardOrder.ShouldBeEmpty(
|
||||||
|
"the find box adds a third condition none of alpha's prod-tagged hosts can pass");
|
||||||
|
|
||||||
|
vault.HostFilter = string.Empty;
|
||||||
|
vault.ResetTagFilterCommand.Execute(null);
|
||||||
|
vault.ResetGroupFilterCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.HostBoardOrder.Select(row => row.Label)
|
||||||
|
.ShouldBe(["a-prod", "a-dev", "b-prod"], ignoreOrder: true, "every filter cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The same label always draws the same two letters and the same hue — across a rebuild, which replaces
|
||||||
|
/// every row object outright, and that is exactly the case worth holding: nothing on the row itself
|
||||||
|
/// survives a reload, so the answer has to come from the label alone.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// FNV-1a rather than <see cref="string.GetHashCode()"/> is the reason this can be asserted at all — see
|
||||||
|
/// <c>HostRowViewModel.MonogramHue</c>. A hash randomised per process would still pass a same-instance
|
||||||
|
/// comparison but never a same-label-after-reload one, which is the case that matters: a card must not
|
||||||
|
/// repaint itself a different colour because the application happened to restart.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheSameLabelDrawsTheSameMonogramAcrossAReload()
|
||||||
|
{
|
||||||
|
await AddHostAsync("prod-db-01");
|
||||||
|
|
||||||
|
var before = Host("prod-db-01");
|
||||||
|
var monogram = before.Monogram;
|
||||||
|
var hue = before.MonogramHue;
|
||||||
|
|
||||||
|
monogram.ShouldBe("pd", "the first letters of the first two hyphen-separated words, lowercased");
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
|
||||||
|
var after = Host("prod-db-01");
|
||||||
|
after.ShouldNotBeSameAs(before, "the reload replaces every row — this is the case worth holding");
|
||||||
|
after.Monogram.ShouldBe(monogram);
|
||||||
|
after.MonogramHue.ShouldBe(hue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The one-word rule is a different branch of <c>HostRowViewModel.Monogram</c> than the two-word one
|
||||||
|
/// above, and worth its own case: it is the first two characters of the label rather than the first
|
||||||
|
/// letter of two words, which for a one-word host name are not the same computation by coincidence.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task AOneWordLabelDrawsItsFirstTwoCharacters()
|
||||||
|
{
|
||||||
|
await AddHostAsync("Bastion");
|
||||||
|
|
||||||
|
Host("Bastion").Monogram.ShouldBe("ba");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
private HostRowViewModel Host(string label) =>
|
||||||
|
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
private async Task AddGroupAsync(string label)
|
||||||
|
{
|
||||||
|
vault.GroupEditorLabel = label;
|
||||||
|
await vault.SaveGroupCommand.ExecuteAsync(Token);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddHostAsync(string label)
|
||||||
|
{
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
vault.EditorLabel = label;
|
||||||
|
vault.EditorHostname = $"{label}.internal";
|
||||||
|
vault.EditorUsername = "deploy";
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddHostInGroupAsync(string label, string groupLabel)
|
||||||
|
{
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
vault.EditorLabel = label;
|
||||||
|
vault.EditorHostname = $"{label}.internal";
|
||||||
|
vault.EditorUsername = "deploy";
|
||||||
|
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||||
|
choice => string.Equals(choice.Label, groupLabel, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddHostInGroupWithTagAsync(string label, string groupLabel, string tag)
|
||||||
|
{
|
||||||
|
await AddHostInGroupAsync(label, groupLabel);
|
||||||
|
|
||||||
|
vault.SelectedHost = Host(label);
|
||||||
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
|
|
||||||
|
vault.EditorNewTag = tag;
|
||||||
|
await vault.AddEditorTagCommand.ExecuteAsync(Token);
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(Token);
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -287,7 +287,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
await OnTheHostsScreenAsync((screen, window) =>
|
await OnTheHostsScreenAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var card = screen.HostGrid.GetVisualDescendants()
|
var card = screen.GetVisualDescendants()
|
||||||
.OfType<ListBoxItem>()
|
.OfType<ListBoxItem>()
|
||||||
.First(item => item.DataContext is HostRowViewModel);
|
.First(item => item.DataContext is HostRowViewModel);
|
||||||
|
|
||||||
@@ -332,22 +332,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await OnTheHostsScreenAsync((screen, _) =>
|
await OnTheHostsScreenAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid);
|
screen.KeyboardTarget.ShouldBeSameAs(screen.Board);
|
||||||
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it");
|
screen.KeyboardTarget.Focus().ShouldBeTrue("the board has cards on it");
|
||||||
});
|
});
|
||||||
|
|
||||||
vault.HostFilter = "nothing matches this";
|
vault.HostFilter = "nothing matches this";
|
||||||
vault.HasVisibleHosts.ShouldBeFalse();
|
vault.HasHostBoardEntries.ShouldBeFalse();
|
||||||
|
|
||||||
await OnTheHostsScreenAsync((screen, _) =>
|
await OnTheHostsScreenAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
|
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
|
||||||
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid is empty, so the find box takes it");
|
screen.KeyboardTarget.Focus().ShouldBeTrue("the board is empty, so the find box takes it");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The editor open with the grid still on screen beside it, which is the state a user is most likely to
|
/// The editor open with the board still on screen beside it, which is the state a user is most likely to
|
||||||
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
|
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -357,56 +357,61 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
await OnTheHostsScreenAsync((screen, _) =>
|
await OnTheHostsScreenAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue();
|
screen.Board.IsEffectivelyVisible.ShouldBeTrue();
|
||||||
screen.KeyboardTarget.Focus().ShouldBeTrue();
|
screen.KeyboardTarget.Focus().ShouldBeTrue();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The grid is cards and nothing else, whatever the vault has been filed into.
|
/// v5: each section's own card list holds cards and nothing else — the heading is a sibling, not a row.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
|
/// This used to hold for one flat grid: no group headings were mixed into the host rows, because the
|
||||||
/// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now.
|
/// group cards above it were where a group's name was drawn. The board replaces that grid with one
|
||||||
/// This asserts the grid's contents rather than only measuring them, because a heading that came back
|
/// section per group — see <c>VaultViewModel.HostSections</c> — and the invariant moves down a level
|
||||||
/// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar
|
/// with it: it is each section's own <c>ListBox</c> (<c>Classes="sectioncards"</c>) that must hold only
|
||||||
/// that spans the width is as inside it as a card is.
|
/// <see cref="HostRowViewModel"/>s, because <c>HostsScreen.axaml.cs</c> finds every card by walking every
|
||||||
|
/// list wearing that class and would misfire on a heading it found mixed in.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Measured with every host filed and that group open, which is the shape that used to produce the most
|
/// Measured with every host filed under one group, which is the shape that puts the most rows behind one
|
||||||
/// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside
|
/// heading.
|
||||||
/// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid.
|
|
||||||
/// See <c>VaultViewModel.Matches</c>.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
|
public async Task EachSectionsCardListHoldsCardsAndNothingElse()
|
||||||
{
|
{
|
||||||
await SeedGroupsAsync(3);
|
await SeedGroupsAsync(3);
|
||||||
|
|
||||||
|
// Filed through the host editor, same as SeedGroupsAsync itself does — the drag this used to exercise
|
||||||
|
// left with the group cards in v5; see VaultViewModel.EditGroup's own remarks.
|
||||||
foreach (var host in vault.Hosts.ToArray())
|
foreach (var host in vault.Hosts.ToArray())
|
||||||
{
|
{
|
||||||
await vault.MoveHostToGroupCommand.ExecuteAsync(
|
vault.SelectedHost = host;
|
||||||
new HostGroupMove(host, vault.Groups[0].EntityId));
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
}
|
|
||||||
|
|
||||||
vault.OpenGroupCommand.Execute(vault.Groups[0]);
|
vault.EditorSelectedGroup = vault.EditorGroupChoices
|
||||||
|
.First(choice => choice.EntityId == vault.Groups[0].EntityId);
|
||||||
|
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
await OnTheHostsScreenAsync((screen, _) =>
|
await OnTheHostsScreenAsync((screen, _) =>
|
||||||
{
|
{
|
||||||
var rows = screen.HostGrid
|
var rows = screen.GetVisualDescendants()
|
||||||
.GetVisualDescendants()
|
.OfType<ListBox>()
|
||||||
.OfType<ListBoxItem>()
|
.Where(list => list.Classes.Contains("sectioncards"))
|
||||||
|
.SelectMany(list => list.GetVisualDescendants().OfType<ListBoxItem>())
|
||||||
.Select(item => item.DataContext)
|
.Select(item => item.DataContext)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
rows.ShouldNotBeEmpty("the seed has to put hosts in the grid");
|
rows.ShouldNotBeEmpty("the seed has to put hosts in one of the sections");
|
||||||
rows.ShouldAllBe(row => row is HostRowViewModel);
|
rows.ShouldAllBe(row => row is HostRowViewModel);
|
||||||
});
|
});
|
||||||
|
|
||||||
await MeasureHostsAsync(
|
await MeasureHostsAsync(
|
||||||
faults => faults.ShouldBeEmpty("with every host filed and the group holding them open"));
|
faults => faults.ShouldBeEmpty("with every host filed under one group and four sections drawn"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -447,15 +452,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
await OnTheHostsScreenAsync((screen, window) =>
|
await OnTheHostsScreenAsync((screen, window) =>
|
||||||
{
|
{
|
||||||
var cards = screen.HostGrid
|
var cards = screen.GetVisualDescendants()
|
||||||
.GetVisualDescendants()
|
|
||||||
.OfType<ListBoxItem>()
|
.OfType<ListBoxItem>()
|
||||||
.Where(item => item.DataContext is HostRowViewModel)
|
.Where(item => item.DataContext is HostRowViewModel)
|
||||||
.Select(item => item.TranslatePoint(default, window)
|
.Select(item => item.TranslatePoint(default, window)
|
||||||
?? throw new InvalidOperationException("a card is not in this window's tree"))
|
?? throw new InvalidOperationException("a card is not in this window's tree"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host in the grid");
|
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host on the board");
|
||||||
|
|
||||||
cards.GroupBy(point => Math.Round(point.Y))
|
cards.GroupBy(point => Math.Round(point.Y))
|
||||||
.Max(row => row.Count())
|
.Max(row => row.Count())
|
||||||
@@ -589,8 +593,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await SeedGroupsAsync(3);
|
await SeedGroupsAsync(3);
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups[0];
|
// Passed directly, the way DeleteGroupFromHeading resolves and hands in a row now that there is no
|
||||||
vault.DeleteGroupCommand.Execute(null);
|
// group-card selection to fall back on.
|
||||||
|
vault.DeleteGroupCommand.Execute(vault.Groups[0]);
|
||||||
|
|
||||||
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||||
vault.PendingDeletion.ShouldNotBeNull().HasChoice
|
vault.PendingDeletion.ShouldNotBeNull().HasChoice
|
||||||
@@ -617,8 +622,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
await SeedGroupsAsync(3);
|
await SeedGroupsAsync(3);
|
||||||
|
|
||||||
vault.SelectedGroup = vault.Groups[0];
|
|
||||||
|
|
||||||
vault.MoveGroupVaultChoices.Add(
|
vault.MoveGroupVaultChoices.Add(
|
||||||
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user