using Avalonia.Controls;
using Avalonia.VisualTree;
using DodoSSH.Client.App.ViewModels;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Client.Transfer;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
///
/// Whether each screen fits in the space the window gives it.
///
///
///
/// This suite used to measure one control, VaultColumn, because there was one. The design import
/// split it in two — the host list lives beside the terminal, and everything else in the vault has a screen
/// of its own — and added a titlebar, a nav rail and a status bar. That is five things to measure, and the
/// split is what keeps every one of them measurable: none contains the terminal's WebView, and
/// MainWindow still cannot be laid out here at all, because WebView2's adapter refuses the headless
/// dispatcher's MTA thread. pins that.
///
///
/// One test per shape a user can put a screen into, because a shape that is never laid out is a shape never
/// checked. The host sidebar has three — list, list with the editor open, and list folded away — and the
/// vault screen has one per category plus one per editor.
///
///
/// A real VaultViewModel over a real unlocked vault, rather than a stand-in. Compiled bindings
/// resolve against the declared data type, so a stand-in would have to be the same type anyway — and the
/// editors' height depends on real content: a key with a real armour block in the box is taller than an
/// empty one.
///
///
public sealed class ScreenLayoutTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// Far below the shipped profile: nothing here attacks a wrap.
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!;
///
/// Over a substitute factory that is never asked for a session. Every shape measured here is one the
/// screen is in before a connection exists or after one has failed, which is deliberate: the two panes
/// are at their widest with the local one full and the remote one carrying its explanation, and a
/// connected pane is the same template with shorter names in it.
///
private TransfersViewModel transfers = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"layout-{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!;
// Never started and never connected through: no screen's layout depends on the terminal, and the
// substitute is here only because the view model's constructor asks for one.
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
Substitute.For(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
// Offline. A null connection is what these screens show on a laptop with no network, and it keeps
// every sync pass out of a suite that is only measuring rectangles.
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
transfers = new TransfersViewModel(
Substitute.For(), TimeProvider.System);
await SeedAsync();
// Attached after seeding, so the host picker has something in it and the local pane has listed this
// machine's home directory — which is what puts real names of real length into the row template.
transfers.Attach(vault, knownHosts);
}
///
public async ValueTask DisposeAsync()
{
await transfers.DisposeAsync();
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
// ---- The host sidebar ----
[Fact]
public async Task TheHostSidebarFitsWithNoEditorOpen()
{
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
///
/// The tight one, and the reason this suite still exists. The sidebar is 268 pixels wide against the old
/// column's 340, and the host editor is the tallest thing in it: six fields, an authentication picker
/// with a two-line item template, a checkbox, a paragraph of hint text and three buttons, all sharing a
/// column with the list above them.
///
[Fact]
public async Task TheHostSidebarFitsWithItsEditorOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorAuthenticationChoices.Count
.ShouldBeGreaterThan(1, "the picker has to be populated for this to measure anything");
// Measured with a credential selected, because an empty picker is shorter than one showing a
// qualifier beside a label.
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
///
/// Folding the list away is the one thing a user can do to this control that changes which of its parts
/// is on screen, so it is a shape worth laying out on its own.
///
[Fact]
public async Task TheHostSidebarFitsWithItsListFoldedAway()
{
vault.ToggleHostsCommand.Execute(null);
vault.AreHostsExpanded.ShouldBeFalse();
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
///
///
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: MainWindow takes
/// the keyboard off the terminal's native child window first and then focuses this target, so a target
/// that cannot take focus leaves the user with no focused element and no way back except the mouse.
///
///
/// Which is why this asserts that focus was taken rather than that the right control was named.
/// A ListBox is not focusable by default, so the call returns false against a list that has not
/// asked to be — and Focus() on a collapsed control is a no-op that is not replayed when it is
/// revealed, which is exactly what the folded-away case would hit.
///
///
[Fact]
public async Task TheSidebarsKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostList);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is showing");
});
vault.ToggleHostsCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostFilter);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is folded away, so the filter takes it");
});
}
///
/// The editor open with the list still on screen behind it, which is the state a user is most likely to
/// leave the sidebar in — so it is the state the keyboard answer most has to hold in.
///
[Fact]
public async Task TheSidebarsKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.HostList.IsEffectivelyVisible.ShouldBeTrue();
sidebar.KeyboardTarget.Focus().ShouldBeTrue();
});
}
// ---- The vault screen ----
[Fact]
public async Task TheVaultScreenFitsInEveryCategory()
{
foreach (var section in new[]
{
VaultSection.All, VaultSection.Keys, VaultSection.Credentials, VaultSection.KnownHosts,
})
{
vault.Section = section;
await MeasureVaultAsync(faults => faults.ShouldBeEmpty($"the {section} category"));
}
}
///
/// The tall one: a private key needs a real text area, and the vault screen's detail pane is 244 pixels
/// wide — the narrowest column any form in this application has to fit into.
///
[Fact]
public async Task TheVaultScreenFitsWithTheKeyEditorOpen()
{
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.ShowsKeys.ShouldBeTrue("opening an editor has to bring its own category into view");
vault.KeyEditorPrivateKey = string.Join(
'\n',
Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheVaultScreenFitsWithThePasswordEditorOpen()
{
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
vault.ShowsCredentials.ShouldBeTrue("opening an editor has to bring its own category into view");
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
///
/// The detail pane with something selected, which is what the design's right-hand column is really about
/// — and the pin is the one carrying a full fingerprint on a wrapped monospace line.
///
[Fact]
public async Task TheVaultScreenFitsWithAPinSelected()
{
vault.Section = VaultSection.KnownHosts;
vault.VaultItems.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
vault.SelectedVaultItem = vault.VaultItems[0];
vault.SelectedItemIsPin.ShouldBeTrue();
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
///
/// The rail is the only way to reach a category, so a button that lands on nothing walls off three
/// quarters of the screen. The fit tests above prove the buttons are inside the window; this proves they
/// are the size a pointer can find, which a zero-height row in a collapsed border would not be.
///
[Fact]
public async Task TheCategoryRailIsBigEnoughToClick()
{
await OnTheVaultAsync((screen, _) =>
{
var buttons = screen.GetVisualDescendants()
.OfType