Public Access
A first group turned the wrap of host cards into an accordion: SidebarRows interleaves a full-width fold-away heading — chevron, name, count — between the cards, and in a grid that reads as a dropdown somebody left open. The desktop grid binds VisibleHosts now. The headings and the fold stay for the phone, whose list has no room for the row of group cards the desktop draws above the grid. What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once per reload like the tag names, drawn as an accent chip and absent from a host in no group — or in one that has been deleted, which is the same thing to look at. What a heading also was is the drop target, and that moves to the group cards. Two things go with it. A host dropped onto another host card used to be filed beside it, which was legible while a heading named the band of cards it landed in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged back out of a group; the way out is the picker in its own editor, which is the one place "no group" can be said in words. A drag held at either edge of the grid scrolls it. Without that the gesture only works for whoever can see both ends of it: the group cards are the first thing in the scroller, the host may be the fortieth card down, and a drag cannot use the wheel. A step per drag event rather than a timer, so it follows the pointer and stops when it stops. The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings asserts the grid's contents rather than only measuring them, because a heading that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost raises a real DragOver over both kinds of card and checks the effects and the mark — the nearest a headless test gets to a gesture no headless test can synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
326 lines
14 KiB
C#
326 lines
14 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Session.Tests;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using DodoSSH.Crypto;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// How the grid of host cards answers a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
|
|
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
|
|
/// <c>HostsScreen.axaml.cs</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
|
|
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
|
|
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because all three of
|
|
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
|
|
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
|
|
/// suite.
|
|
/// </para>
|
|
/// <para>
|
|
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
|
|
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
|
|
/// hosts and groups.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class HostGridTests : IAsyncLifetime
|
|
{
|
|
private const string Passphrase = "a sufficiently long passphrase";
|
|
private const string ServerUrl = "https://dodossh.example";
|
|
|
|
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
|
private static readonly Argon2Profile CheapProfile =
|
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
|
|
|
private readonly FakeAccountServer server = new();
|
|
private readonly StubKeyBinding keyBinding = new();
|
|
private readonly VaultKnownHostStore knownHosts = new();
|
|
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private VaultSession session = null!;
|
|
private VaultViewModel vault = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask InitializeAsync()
|
|
{
|
|
caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}");
|
|
await caches.MigrateAsync(Token);
|
|
|
|
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
|
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
|
|
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
|
session = outcome.Session!;
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
await knownHosts.OpenAsync(session, Token);
|
|
|
|
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
|
|
|
await SeedAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
knownHosts.Close();
|
|
await workspace.DisposeAsync();
|
|
await vault.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The rule the menu depends on. Without it the three commands would act on whatever was selected
|
|
/// before, which for Delete is a question asked about one machine and answered about another.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickSelectsTheHostUnderThePointer()
|
|
{
|
|
await OnTheGridAsync((screen, window) =>
|
|
{
|
|
var first = Row(vault, "prod-db");
|
|
var other = Row(vault, "stage-web");
|
|
|
|
vault.SelectedHost = first;
|
|
|
|
RightClick(CardFor(screen, other), window);
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(other);
|
|
|
|
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
|
|
menu.IsOpen.ShouldBeTrue();
|
|
|
|
// The commands are the vault's, which is the other half of putting the menu on the list rather
|
|
// than in the item template: a menu inside the template would have the row for its data context,
|
|
// and every one of these would silently bind to nothing.
|
|
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
|
|
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
|
|
|
|
edit.Command!.Execute(null);
|
|
|
|
vault.IsEditing.ShouldBeTrue();
|
|
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
|
|
/// Delete over it would be three entries acting on whichever machine happened to be selected — which is
|
|
/// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
|
|
/// </para>
|
|
/// <para>
|
|
/// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
|
|
/// the event's source, and a source that is the list rather than an item is exactly what the empty space
|
|
/// produces; a coordinate would additionally be asserting where the wrap put the cards.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var selected = Row(vault, "prod-db");
|
|
vault.SelectedHost = selected;
|
|
|
|
screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
|
|
{
|
|
RoutedEvent = Control.ContextRequestedEvent,
|
|
Source = screen.HostGrid,
|
|
});
|
|
|
|
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
|
|
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// A host held over a group card would be filed there, and one held over another host card would not.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The group headings that used to sit between the cards are gone — see
|
|
/// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — and with them went the thing a
|
|
/// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card
|
|
/// dropped onto another card used to file it beside that one, which was legible while a heading said
|
|
/// which group the band of cards belonged to and is guesswork now that none does.
|
|
/// </para>
|
|
/// <para>
|
|
/// What is <em>not</em> here is the platform's half — picking the card up, the cursor, the drop itself.
|
|
/// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the
|
|
/// gesture is <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
|
|
/// covers is in docs/manual-checks.md 7.6.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var carried = new DataTransfer();
|
|
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
|
|
|
|
var onto = screen.GroupGrid
|
|
.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.Single(item => item.DataContext is HostGroupRowViewModel);
|
|
|
|
var over = Over(onto, carried);
|
|
|
|
onto.Classes.ShouldContain("droptarget", "the card says it would take the host");
|
|
over.DragEffects.ShouldBe(DragDropEffects.Move);
|
|
|
|
var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried);
|
|
|
|
refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform");
|
|
refused.DragEffects.ShouldBe(
|
|
DragDropEffects.None,
|
|
"a card dropped onto another card would be filed somewhere nothing on screen names");
|
|
|
|
// And the group card it was over a moment ago stops offering to take it, which is the half of
|
|
// this that is wrong far more often than the mark appearing at all.
|
|
onto.Classes.ShouldNotContain("droptarget");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
|
|
/// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
|
|
/// what is worth holding is the rule — the filter is a property of the grid, and it also moves the
|
|
/// selection the group's own EDIT and DELETE act on. A click would test Avalonia's <c>SelectedItem</c>
|
|
/// binding, which is not this application's code.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt()
|
|
{
|
|
var production = vault.Groups.Single();
|
|
|
|
vault.MoveHostToGroupCommand.Execute(
|
|
new HostGroupMove(Row(vault, "prod-db"), production.EntityId));
|
|
|
|
vault.GroupFilter = production;
|
|
|
|
vault.VisibleHosts.Select(row => row.Label)
|
|
.ShouldBe(["prod-db"], "only what is filed under the chosen group");
|
|
|
|
vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on");
|
|
vault.IsFilteredByGroup.ShouldBeTrue();
|
|
|
|
vault.ClearGroupFilterCommand.Execute(null);
|
|
|
|
vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group");
|
|
vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
|
/// <remarks>
|
|
/// Declared again here rather than made visible, because what the two have in common is the contract —
|
|
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
|
|
/// started carrying something else under it.
|
|
/// </remarks>
|
|
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
|
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
|
|
|
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
|
|
/// <remarks>
|
|
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
|
|
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
|
|
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
|
|
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
|
|
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
|
|
/// </remarks>
|
|
private static DragEventArgs Over(Interactive target, DataTransfer carried)
|
|
{
|
|
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
|
|
|
|
target.RaiseEvent(over);
|
|
|
|
return over;
|
|
}
|
|
|
|
private static void RightClick(Visual row, Visual window)
|
|
{
|
|
var at = Centre(row, window);
|
|
|
|
((Window)window).MouseDown(at, MouseButton.Right);
|
|
((Window)window).MouseUp(at, MouseButton.Right);
|
|
}
|
|
|
|
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new HostsScreen { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(screen, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
|
|
screen.GetVisualDescendants()
|
|
.OfType<ListBoxItem>()
|
|
.First(item => ReferenceEquals(item.DataContext, host));
|
|
|
|
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
|
|
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
|
|
|
private static Point Centre(Visual control, Visual window) =>
|
|
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the control is not in this window's tree");
|
|
|
|
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
|
|
private async Task SeedAsync()
|
|
{
|
|
foreach (var label in new[] { "prod-db", "stage-web" })
|
|
{
|
|
vault.NewHostCommand.Execute(null);
|
|
vault.EditorLabel = label;
|
|
vault.EditorHostname = $"{label}.internal";
|
|
vault.EditorUsername = "deploy";
|
|
|
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
}
|
|
|
|
vault.GroupEditorLabel = "production";
|
|
await vault.SaveGroupCommand.ExecuteAsync(null);
|
|
|
|
await vault.LoadAsync(Token);
|
|
}
|
|
}
|