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, and the only thing keeping it
/// from clipping its own Save button at the window's minimum height is a state rule — one editor open at a
/// time. That rule was added on the strength of an argument, not a measurement, and this suite is the
/// measurement.
///
///
/// 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 TheColumnFitsWithNoEditorOpen()
{
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheColumnFitsWithTheHostEditorOpen()
{
vault.NewHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task TheColumnFitsWithTheKeyEditorOpen()
{
// The tall one: a private key needs a real text area, and this is the editor the MaxHeight on the key
// list exists to make room for.
vault.NewKeyCommand.Execute(null);
vault.IsEditingKey.ShouldBeTrue();
vault.KeyEditorPrivateKey = string.Join(
'\n',
Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
await MeasureAsync(faults => faults.ShouldBeEmpty());
}
[Fact]
public async Task BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists()
{
// The justification for KeyEditorIsInTheWay/HostEditorIsInTheWay, turned from an argument in a comment
// into a number. The flags are set directly because the commands refuse this on purpose — the point is
// to measure what the refusal is protecting.
//
// If this test ever starts passing, the rule has become unnecessary and the comments claiming it is
// load-bearing have become false. That is a finding, not a flake: read it as "the column has room
// now", check what changed, and delete the rule rather than this test.
vault.IsEditing = true;
vault.IsEditingKey = true;
await MeasureAsync(faults => faults.ShouldNotBeEmpty(
"one editor at a time is a workaround for a column that cannot hold two"));
}
/// Lays the column out at the size the window gives it and hands the faults to an assertion.
private Task MeasureAsync(Action> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = LayoutHarness.HostAtMinimumSize(
new VaultColumn { DataContext = vault },
LayoutHarness.VaultColumnWidth,
LayoutHarness.VaultColumnHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
///
/// Enough rows that the lists are not empty, because an empty list is the easiest case and the one least
/// worth certifying.
///
private async Task SeedAsync()
{
for (var i = 0; i < 6; i++)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = $"host-{i}";
vault.EditorHostname = $"host-{i}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
await vault.LoadAsync(Token);
}
}