Flatten the hosts screen into one board of sections

This commit is contained in:
2026-08-07 18:12:22 +02:00
parent 369109dd4e
commit c3ef4bd8b4
4 changed files with 1133 additions and 1284 deletions
@@ -18,26 +18,36 @@ using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the grid of host cards answers a pointer.
/// 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>.
/// 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 screen's menus read the vault's selection. A menu that quietly acted on whichever host
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
/// quieter still.
/// 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 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.
/// </para>
/// </remarks>
@@ -102,27 +112,24 @@ public sealed class HostGridTests : IAsyncLifetime
[Fact]
public async Task ARightClickSelectsTheHostUnderThePointer()
{
await OnTheGridAsync((screen, window) =>
await OnTheBoardAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
vault.SelectedHost = first;
RightClick(CardFor(screen, other), window);
var card = CardFor(screen, other);
RightClick(card, window);
vault.SelectedHost.ShouldBeSameAs(other);
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
var menu = SectionMenuFor(card).ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
// The commands are the vault's, which is the other half of putting the menu on the list rather
// than in the item template: a menu inside the template would have the row for its data context,
// and every one of these would silently bind to nothing.
//
// 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.
// 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);
@@ -135,388 +142,38 @@ public sealed class HostGridTests : IAsyncLifetime
}
/// <remarks>
/// <para>
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
/// </para>
/// <para>
/// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
/// the event's source, and a source that is the list rather than an item is exactly what the empty space
/// produces; a coordinate would additionally be asserting where the wrap put the cards.
/// </para>
/// 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 OnTheGridAsync((screen, _) =>
await OnTheBoardAsync((screen, _) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
screen.Board.RaiseEvent(new ContextRequestedEventArgs
{
RoutedEvent = Control.ContextRequestedEvent,
Source = screen.HostGrid,
Source = screen.Board,
});
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>
/// The same rule on the cards above, where getting it wrong is quieter and worse.
/// </summary>
/// <remarks>
/// <para>
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, 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.
/// Choosing a host costs nothing, and the pencil on its card is what spends the 320 pixels.
/// </summary>
/// <remarks>
/// <para>
@@ -527,13 +184,14 @@ public sealed class HostGridTests : IAsyncLifetime
/// <para>
/// Driven through the card's own button rather than by executing the command, since the thing most
/// likely to rot is the binding that reaches out of the item template to the vault's command — a
/// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
/// <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 OnTheGridAsync((screen, _) =>
await OnTheBoardAsync((screen, _) =>
{
var host = Row(vault, "stage-web");
@@ -559,14 +217,12 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
/// selection that goes away entirely has to take it with it. Without that the flag would survive a
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
/// which is the behaviour the pencil exists to remove.
/// selection that goes away entirely has to take it with it.
/// </remarks>
[Fact]
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
{
await OnTheGridAsync((_, _) =>
await OnTheBoardAsync((_, _) =>
{
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
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
// with a pointer instead of a long press. What these hold is the half that belongs to this head: which
// gesture means what, and the rule that keeps the list's own selection and the set from ever both being
// about something at the same moment. See HostsScreen.axaml.cs.
// 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 the <c>ListBox</c>'s own mark would light the card it had just unticked and open the drawer
/// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
/// down rather than acted on as it bubbles.
/// 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
@@ -601,7 +257,7 @@ public sealed class HostGridTests : IAsyncLifetime
[Fact]
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
{
await OnTheGridAsync((screen, window) =>
await OnTheBoardAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var second = Row(vault, "stage-web");
@@ -614,7 +270,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.ChosenHostCount.ShouldBe(1);
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(2);
@@ -632,16 +288,17 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
/// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
/// the grid's own, so "between" means between as the cards are laid out.
/// 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 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");
@@ -665,8 +322,7 @@ public sealed class HostGridTests : IAsyncLifetime
/// <para>
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
/// selection or the start of a drag of that host, and the band is what the space between and below them
/// is for. The rectangle ticks what it touches rather than what it swallows — a card is 232 pixels wide,
/// and a band that had to contain one would tick nothing at all when drawn down a column.
/// 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
@@ -676,7 +332,7 @@ public sealed class HostGridTests : IAsyncLifetime
[Fact]
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
{
await OnTheGridAsync((screen, window) =>
await OnTheBoardAsync((screen, window) =>
{
var first = CardFor(screen, Row(vault, "prod-db"));
var last = CardFor(screen, Row(vault, "stage-web"));
@@ -718,23 +374,24 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
/// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
/// must never happen is both being offered at once: a Delete… asking about the card under the pointer
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
/// 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 OnTheGridAsync((screen, window) =>
await OnTheBoardAsync((screen, window) =>
{
var ticked = Row(vault, "prod-db");
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();
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
@@ -748,22 +405,23 @@ public sealed class HostGridTests : IAsyncLifetime
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.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)
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
});
}
/// <remarks>
/// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
/// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
/// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
/// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
/// 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()
@@ -772,14 +430,14 @@ public sealed class HostGridTests : IAsyncLifetime
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");
screen.HostGrid.Focus();
screen.Board.Focus();
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);
@@ -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 ----
/// <summary>The same in-process format the screen's own drag carries.</summary>
/// <remarks>
/// Declared again here rather than made visible, because what the two have in common is the contract —
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
/// started carrying something else under it.
/// </remarks>
private static readonly DataFormat<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)
private static void RightClick(Visual card, Visual window)
{
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
target.RaiseEvent(over);
return over;
}
private static void RightClick(Visual row, Visual window)
{
var at = Centre(row, window);
var at = Centre(card, window);
((Window)window).MouseDown(at, MouseButton.Right);
((Window)window).MouseUp(at, MouseButton.Right);
@@ -874,7 +479,7 @@ public sealed class HostGridTests : IAsyncLifetime
control.TranslatePoint(default, window)
?? 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(
() =>
{
@@ -894,53 +499,27 @@ public sealed class HostGridTests : IAsyncLifetime
},
Token);
private static ListBoxItem GroupCard(HostsScreen screen) =>
screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
private static ListBoxItem CardFor(Visual screen, object row) =>
/// <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));
/// <remarks>
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
/// </remarks>
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
vault.VisibleGroups.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
private async Task AddGroupAsync(string label)
{
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
private async Task FileGroupUnderAsync(string group, string parent)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
private async Task AddHostAsync(string label)
{
@@ -953,7 +532,11 @@ public sealed class HostGridTests : IAsyncLifetime
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()
{
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) =>
{
var card = screen.HostGrid.GetVisualDescendants()
var card = screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is HostRowViewModel);
@@ -332,22 +332,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it");
screen.KeyboardTarget.ShouldBeSameAs(screen.Board);
screen.KeyboardTarget.Focus().ShouldBeTrue("the board has cards on it");
});
vault.HostFilter = "nothing matches this";
vault.HasVisibleHosts.ShouldBeFalse();
vault.HasHostBoardEntries.ShouldBeFalse();
await OnTheHostsScreenAsync((screen, _) =>
{
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>
/// 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.
/// </remarks>
[Fact]
@@ -357,56 +357,61 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await OnTheHostsScreenAsync((screen, _) =>
{
screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue();
screen.Board.IsEffectivelyVisible.ShouldBeTrue();
screen.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <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>
/// <remarks>
/// <para>
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
/// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now.
/// This asserts the grid's contents rather than only measuring them, because a heading that came back
/// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar
/// that spans the width is as inside it as a card is.
/// This used to hold for one flat grid: no group headings were mixed into the host rows, because the
/// group cards above it were where a group's name was drawn. The board replaces that grid with one
/// section per group — see <c>VaultViewModel.HostSections</c> — and the invariant moves down a level
/// with it: it is each section's own <c>ListBox</c> (<c>Classes="sectioncards"</c>) that must hold only
/// <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>
/// Measured with every host filed and that group open, which is the shape that used to produce the most
/// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside
/// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid.
/// See <c>VaultViewModel.Matches</c>.
/// Measured with every host filed under one group, which is the shape that puts the most rows behind one
/// heading.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
public async Task EachSectionsCardListHoldsCardsAndNothingElse()
{
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())
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(host, vault.Groups[0].EntityId));
}
vault.SelectedHost = host;
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, _) =>
{
var rows = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
var rows = screen.GetVisualDescendants()
.OfType<ListBox>()
.Where(list => list.Classes.Contains("sectioncards"))
.SelectMany(list => list.GetVisualDescendants().OfType<ListBoxItem>())
.Select(item => item.DataContext)
.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);
});
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>
@@ -447,15 +452,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await OnTheHostsScreenAsync((screen, window) =>
{
var cards = screen.HostGrid
.GetVisualDescendants()
var cards = screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.Where(item => item.DataContext is HostRowViewModel)
.Select(item => item.TranslatePoint(default, window)
?? throw new InvalidOperationException("a card is not in this window's tree"))
.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))
.Max(row => row.Count())
@@ -589,8 +593,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
await SeedGroupsAsync(3);
vault.SelectedGroup = vault.Groups[0];
vault.DeleteGroupCommand.Execute(null);
// Passed directly, the way DeleteGroupFromHeading resolves and hands in a row now that there is no
// 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.PendingDeletion.ShouldNotBeNull().HasChoice
@@ -617,8 +622,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
await SeedGroupsAsync(3);
vault.SelectedGroup = vault.Groups[0];
vault.MoveGroupVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));