using Avalonia.Controls;
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.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
///
/// Whether the vault column fits in the space the window gives it.
///
///
///
/// The column is 340 pixels wide and holds a list and an editor per item type, of which it shows one type at a
/// time. This suite is the measurement behind that arrangement: the column used to stack both types and keep
/// itself from clipping its own Save button with a state rule — one editor open at a time — and that rule was
/// added on the strength of an argument. The argument was right about the stacked column and is now moot,
/// which is a thing this suite found rather than assumed. See
/// .
///
///
/// One test per section, and one per section with its editor open, because that is the full set of shapes a
/// user can put this column into. A third section will add two more.
///
///
/// 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 VaultColumnLayoutTests : 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!;
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: the column's layout does not depend 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 the column shows 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);
await SeedAsync();
}
///
public async ValueTask DisposeAsync()
{
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task TheHostsSectionFitsWithNoEditorOpen()
{
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheHostsSectionFitsWithItsEditorOpen()
{
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheKeysSectionFitsWithNoEditorOpen()
{
vault.ShowSectionCommand.Execute(VaultSection.Keys);
vault.ShowsKeys.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheKeysSectionFitsWithItsEditorOpen()
{
// The tall one: a private key needs a real text area, and this editor is what the key list used to
// hide itself and cap its own height for. Both workarounds are gone, so this measurement is now the
// only thing saying they were not needed.
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.ShowsKeys.ShouldBeTrue("opening an editor has to bring its own section into view");
vault.KeyEditorPrivateKey = string.Join(
'\n',
Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheCredentialsSectionFitsWithNoEditorOpen()
{
vault.ShowSectionCommand.Execute(VaultSection.Credentials);
vault.ShowsCredentials.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheCredentialsSectionFitsWithItsEditorOpen()
{
vault.NewCredentialCommand.Execute(null);
vault.IsEditingCredential.ShouldBeTrue();
vault.ShowsCredentials.ShouldBeTrue("opening an editor has to bring its own section into view");
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
///
/// The only section with no editor, so it has only the one shape — but the tallest rows, because each
/// carries a full fingerprint on a wrapped monospace line rather than a one-word description.
///
[Fact]
public async Task TheHostKeysSectionFits()
{
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
vault.ShowsKnownHosts.ShouldBeTrue();
vault.KnownHostPins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
///
/// The host editor is the one a third item type made taller: its authentication picker is now a ComboBox
/// with a two-line-capable item template, and the section it sits in is the only one holding a
/// NumericUpDown, a CheckBox and two paragraphs of hint text. Measured with the picker
/// populated, because an empty ComboBox is shorter than one showing a qualifier beside a label.
///
[Fact]
public async Task TheHostEditorFitsWithTheAuthenticationPickerFull()
{
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");
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task BothEditorsOpen_NowFit_BecauseOnlyOneSectionIsLaidOut()
{
// This test used to assert the opposite, and its own comment said that if it ever started passing the
// rule it justified had become unnecessary. That has happened, and this is the record of it: the two
// editors are in different sections now and only one section is laid out, so the sizing argument for
// one-editor-at-a-time is dead.
//
// The rule itself is not, and AnEditorIsInTheWay says why — an open key editor holds a pasted private
// key, and moving on would leave it in a form nobody can see. That is a state rule with a state
// reason, so it belongs in the shell's tests and not here. This suite's job was the sizing claim, and
// the honest thing to do with a measurement that has flipped is to keep measuring it.
vault.IsEditing = true;
vault.IsEditingKey = true;
await MeasureAsync(faults => faults.ShouldBeEmpty(
"one section at a time means two open editors are never laid out together"));
vault.Section = VaultSection.Keys;
await MeasureAsync(faults => faults.ShouldBeEmpty(
"and the same holds from the other side, where the taller editor is the visible one"));
}
///
///
/// 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.
/// Naming is the cheap half and it was already right; taking it was not — a ListBox is not focusable
/// by default, so this call returned false against the column as it stood and the shipped release-the-
/// keyboard path did nothing. Two ways to fail, and only the assertion that runs the call sees both: a
/// control in the section that is not showing is collapsed, and Focus() on a collapsed control is a
/// no-op that is not replayed when it is revealed.
///
///
[Fact]
public async Task TheKeyboardTargetIsTheListThatIsOnScreenAndItTakesFocus()
{
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.HostList);
column.KeyboardTarget.Focus().ShouldBeTrue("the hosts section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.Keys);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.KeyList);
column.KeyboardTarget.Focus().ShouldBeTrue("the keys section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.Credentials);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.CredentialList);
column.KeyboardTarget.Focus().ShouldBeTrue("the credentials section is showing");
});
vault.ShowSectionCommand.Execute(VaultSection.KnownHosts);
await OnTheColumnAsync((column, _) =>
{
column.KeyboardTarget.ShouldBeSameAs(column.KnownHostList);
column.KeyboardTarget.Focus().ShouldBeTrue("the host keys section is showing");
});
}
///
/// The same call in the state the section rule allows: an editor open, its own list still on screen behind
/// it. The key list used to collapse itself whenever its editor opened, so a target that followed the
/// section would have been a no-op in exactly the state a user is most likely to leave the terminal in.
///
[Fact]
public async Task TheKeyboardTargetStillTakesFocusWithAnEditorOpen()
{
vault.NewKeyCommand.Execute(null);
await OnTheColumnAsync((column, _) =>
{
column.KeyList.IsEffectivelyVisible.ShouldBeTrue();
column.KeyboardTarget.Focus().ShouldBeTrue();
});
}
///
/// The claim the whole arrangement rests on, and the one nothing else here would notice breaking: two
/// sections left visible at once would overlap in the row they share rather than clip, so every fit test
/// above would still pass while the column showed one list through another.
///
[Fact]
public async Task OnlyOneSectionIsOnScreenAtOnce()
{
await AssertOnlyVisibleAsync(VaultSection.Hosts);
await AssertOnlyVisibleAsync(VaultSection.Keys);
await AssertOnlyVisibleAsync(VaultSection.Credentials);
await AssertOnlyVisibleAsync(VaultSection.KnownHosts);
}
/// Shows one section and checks that it is the only one a user can see.
private async Task AssertOnlyVisibleAsync(VaultSection section)
{
vault.Section = section;
await OnTheColumnAsync((column, _) =>
{
var lists = new Dictionary
{
[VaultSection.Hosts] = column.HostList,
[VaultSection.Keys] = column.KeyList,
[VaultSection.Credentials] = column.CredentialList,
[VaultSection.KnownHosts] = column.KnownHostList,
};
foreach (var (owner, list) in lists)
{
list.IsEffectivelyVisible.ShouldBe(
owner == section,
$"{owner} showing while {section} is selected");
}
});
}
///
/// The selector is the only way to reach a section, so a click that lands on nothing is a column with one
/// half of it walled off. Its buttons are covered by every fit test above — the harness treats a
/// as interactive — but that only proves they are inside the window. This proves they
/// are the size a pointer can find, which a zero-height row of buttons in a collapsed border would not be.
///
[Fact]
public async Task TheSelectorIsBigEnoughToClick()
{
await OnTheColumnAsync((column, _) =>
{
var buttons = column.SectionSelector.Children.OfType