Public Access
THE DRAWER USED TO ARRIVE WITH THE SELECTION. IsDrawerOpen read "a host is selected", so touching any card took 304 pixels off the grid — including every card arrowed past on the way to the one somebody wanted. Choosing among forty machines was charged the price of the pane for one of them. A pencil now appears on the card under the pointer and on the selected card, and that is what opens it; IsHostPaneOpen is the flag, and the grid's context menu gained Details… so the pane is reachable without a pointer, which a hover-only control is not. Once open it follows the selection rather than pinning the host it was opened on: a pane about one host beside a grid marking a different one is two answers to the same question. Losing the selection closes it and clears the flag, or a filter matching nothing would leave the pane armed to spring open again on the next card merely selected — which is the behaviour the pencil exists to remove. The pencil is drawn over the card rather than in a column of its own. A column would have cost the name 30 pixels of a 232-pixel tile, permanently, for a control that is only there while the pointer is; the dot and the pencil stack in the two corners of that edge instead. IsVisible and not opacity, because a button at zero opacity still takes the click and the card underneath does not. A HEADER, A BODY THAT SCROLLS, AND A FOOTER, which is the one structural change in the pane. The header names what the drawer is about and carries the two things true of every panel; the footer carries the one thing each panel is for — CONNECT, or SAVE, or the question about deleting. Only the middle scrolls, so the button somebody came here to press can no longer be below the fold, which CONNECT could be on a host with fifteen tags. That also widens what the layout harness certifies: it skips anything inside a ScrollViewer, and the control each panel exists to offer is now outside one. THE SAME THREE CARDS TWICE. Address, General, Connection — first as rows stating what the host is, then as boxes for changing it. The detail pane's rows are buttons that open the editor: the design draws every fact as a filled box, and rather than draw an input that refuses the pointer, pressing one leads to the same card with a real box in it. Nothing here saves as you type, and that is not timidity — saving validates the key-or-credential exclusion and writes one encrypted payload, so a box committing per keystroke would be a save per character and a half-typed hostname on the wire. Every value the pane prints is the resolved one, and says "inherited" beside it where a group supplied it. The number is the same either way and the edit is not: clearing a group's default moves every host that never overrode it. A HOST CARD IS TWO LINES AND NO CHIPS. The subtitle is now "ssh, root, pci, eu-west-1" — the transport, the resolved account, then every tag — replacing both the user@host:port line and the wrapped row of tag chips under it. The address went to the card's tooltip rather than nowhere: a card is read while scanning forty machines, where the name and the kind of machine are what is being looked for, and an address is what you read once you have found it. "ssh" is a constant today and is printed anyway, which is the one thing here that argues with this codebase's own rule about constants dressed as readings. It is the first item of a list whose other items vary, and a list beginning with the account on one card and a tag on the next has no shape to scan. The remark on HostRowViewModel.Summary says so rather than leaving it to be discovered. WHAT THE DESIGN DRAWS AND THIS PANE HAS NOT GOT: Share this host, Add Telnet, "SSH ID, Certificate, FIDO2", the backspace-key mapping row, the vault picker's chevron and Show more. Sharing is per vault and not per item, every session here is an SSH channel, there are no identity or certificate item types, nothing carries a terminal setting to the renderer, and an item cannot be moved between vaults at all. Six controls with nothing behind them, listed in docs/design-import-gaps.md with what ships instead, and none drawn disabled. The credentials row is marked with ◆ rather than the ⚿ the nav rail uses for the keychain. U+26BF is outside both faces this application substitutes for the design's fonts, so it lands on whatever the platform's fallback has; every other glyph in the pane is from Geometric Shapes, which both carry.
390 lines
16 KiB
C#
390 lines
16 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The two halves are one rule and are asserted together, because either alone would pass on a broken
|
|
/// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the
|
|
/// second. What is being held is that opening is <em>deliberate</em>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Driven through the card's own button rather than by executing the command, since the thing most
|
|
/// likely to rot is the binding that reaches out of the item template to the vault's command — a
|
|
/// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
|
|
{
|
|
await OnTheGridAsync((screen, _) =>
|
|
{
|
|
var host = Row(vault, "stage-web");
|
|
|
|
vault.SelectedHost = host;
|
|
|
|
vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane");
|
|
|
|
// The button is hidden until the pointer is on the card, so a click cannot be synthesised at a
|
|
// point: what a headless run can reach is the control and the command behind it.
|
|
var pencil = CardFor(screen, host)
|
|
.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.Classes.Contains("rowedit"));
|
|
|
|
pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
|
|
pencil.Command.Execute(pencil.CommandParameter);
|
|
|
|
vault.IsDrawerOpen.ShouldBeTrue();
|
|
vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
|
|
vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
|
|
/// selection that goes away entirely has to take it with it. Without that the flag would survive a
|
|
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
|
|
/// which is the behaviour the pencil exists to remove.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
|
|
{
|
|
await OnTheGridAsync((_, _) =>
|
|
{
|
|
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
|
|
vault.IsDrawerOpen.ShouldBeTrue();
|
|
|
|
vault.SelectedHost = null;
|
|
vault.IsDrawerOpen.ShouldBeFalse();
|
|
|
|
vault.SelectedHost = Row(vault, "stage-web");
|
|
vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
|
|
});
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>The same in-process format the screen's own drag carries.</summary>
|
|
/// <remarks>
|
|
/// Declared again here rather than made visible, because what the two have in common is the contract —
|
|
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
|
|
/// started carrying something else under it.
|
|
/// </remarks>
|
|
private static readonly DataFormat<HostRowViewModel> HostFormat =
|
|
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
|
|
|
|
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
|
|
/// <remarks>
|
|
/// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
|
|
/// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
|
|
/// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
|
|
/// the host, and whether it is marked while it is being held there. The drop itself only repeats that
|
|
/// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
|
|
/// </remarks>
|
|
private static DragEventArgs Over(Interactive target, DataTransfer carried)
|
|
{
|
|
var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);
|
|
|
|
target.RaiseEvent(over);
|
|
|
|
return over;
|
|
}
|
|
|
|
private static void RightClick(Visual row, Visual window)
|
|
{
|
|
var at = Centre(row, window);
|
|
|
|
((Window)window).MouseDown(at, MouseButton.Right);
|
|
((Window)window).MouseUp(at, MouseButton.Right);
|
|
}
|
|
|
|
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var screen = new HostsScreen { DataContext = vault };
|
|
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(screen, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private static ListBoxItem 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);
|
|
}
|
|
}
|