using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
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.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!;
///
/// Constructed and never started: the sign-out card binds to the shell rather than to a vault, and what
/// it shows comes from properties a fresh one already answers. Starting it would migrate a cache and
/// read a profile, neither of which any rectangle here depends on.
///
private MainWindowViewModel shell = 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);
shell = new MainWindowViewModel(
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-layout-{Guid.CreateVersion7():N}")),
caches,
workspace,
knownHosts,
new UnavailableDeviceKeyStore(),
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
TimeProvider.System,
Substitute.For(),
CheapProfile);
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 shell.DisposeAsync();
await transfers.DisposeAsync();
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
// ---- The hosts drawer ----
//
// This was the host sidebar's section. The control kept the half of that column that is about one host
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
// the right.
///
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
/// when the pencil arrived: a selected host no longer puts the pane up, so a test that only selected one
/// would measure a drawer with all three panels collapsed and pass on an empty column. See
/// VaultViewModel.IsHostPaneOpen.
///
[Fact]
public async Task TheHostDrawerFitsShowingAHost()
{
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsShowingHostDetail.ShouldBeTrue("there is nothing to measure otherwise");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
///
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
/// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons.
///
[Fact]
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
{
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 MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
///
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
/// three pickers are the same width as the host editor's and its labels are longer.
///
[Fact]
public async Task TheHostDrawerFitsWithTheGroupEditorOpen()
{
await SeedGroupsAsync(3);
vault.GroupFilter = vault.Groups[0];
vault.EditGroupCommand.Execute(null);
vault.IsEditingGroup.ShouldBeTrue("the desktop raises this now, as the phone always did");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
///
///
/// The question in place of the three buttons. Its tallest shape is a host with a terminal open on it,
/// which adds a disclosure the ordinary case has not got.
///
///
/// Still worth measuring although the drawer scrolls as a whole now — see HostDrawer.axaml — and
/// the reason has changed rather than gone. The harness skips anything inside a ScrollViewer, so
/// what this holds is not that the buttons are on screen but that the drawer itself does not blow its
/// column sideways. The question is the widest thing it draws: a sentence with a host name in it.
///
///
[Fact]
public async Task TheHostDrawerFitsWithADeletionInQuestion()
{
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.SelectedHost.ShouldNotBeNull().IsConnected = true;
vault.DeleteHostCommand.Execute(null);
vault.IsConfirmingDeletion.ShouldBeTrue();
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
///
///
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
/// The gesture is wired in the control rather than bound in the markup, which is exactly the sort of
/// wiring that compiles whether or not it is connected to anything — so it is worth a test that
/// performs the gesture.
///
///
/// Proved through a connection that is refused before any network is involved. The host is left bound
/// to a key that has been deleted, which TryBuildAuthentication turns into a sentence on the
/// status line rather than a socket — so what this asserts is that the command ran, with nothing
/// timing out to make it flaky.
///
///
[Fact]
public async Task DoubleClickingAHostConnectsToIt()
{
var keyId = vault.Keys[0].EntityId;
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedKey = vault.Keys.Single(row => row.EntityId == keyId);
vault.DeleteKeyCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.SelectedHost = null;
vault.Status = string.Empty;
await OnTheHostsScreenAsync((screen, window) =>
{
var card = screen.HostGrid.GetVisualDescendants()
.OfType()
.First(item => item.DataContext is HostRowViewModel);
var centre = card.TranslatePoint(
new Point(card.Bounds.Width / 2, card.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the card is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldNotBeNull("a press on a card selects it");
vault.Status.ShouldContain(
"not in this keychain any more",
Case.Insensitive,
"the double-click has to reach the connect command");
});
}
///
///
/// 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 an empty one has no item to take it either, which is the second shape below.
///
///
/// The empty shape used to be the sidebar's folded-away list and is now a filter that matches nothing.
/// That is a state a user reaches far more often than the old one: it is one keystroke away from every
/// search.
///
///
[Fact]
public async Task TheHostsScreensKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it");
});
vault.HostFilter = "nothing matches this";
vault.HasVisibleHosts.ShouldBeFalse();
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid is empty, so the find box takes it");
});
}
///
/// The editor open with the grid still on screen beside it, which is the state a user is most likely to
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
///
[Fact]
public async Task TheHostsScreensKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheHostsScreenAsync((screen, _) =>
{
screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue();
screen.KeyboardTarget.Focus().ShouldBeTrue();
});
}
///
/// The grid is cards and nothing else, whatever the vault has been filed into.
///
///
///
/// It used to hold the group headings too — a full-width fold-away bar between the cards for each group
/// — and in a wrap of cards that read as a dropdown somebody had left open. They are the phone's now.
/// This asserts the grid's contents rather than only measuring them, because a heading that came back
/// would lay out perfectly cleanly: the harness asks whether a control is inside the window, and a bar
/// that spans the width is as inside it as a card is.
///
///
/// Measured with every host filed and that group open, which is the shape that used to produce the most
/// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside
/// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid.
/// See VaultViewModel.Matches.
///
///
[Fact]
public async Task TheHostsGridHoldsCardsAndNoGroupHeadings()
{
await SeedGroupsAsync(3);
foreach (var host in vault.Hosts.ToArray())
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(host, vault.Groups[0].EntityId));
}
vault.OpenGroupCommand.Execute(vault.Groups[0]);
await OnTheHostsScreenAsync((screen, _) =>
{
var rows = screen.HostGrid
.GetVisualDescendants()
.OfType()
.Select(item => item.DataContext)
.ToList();
rows.ShouldNotBeEmpty("the seed has to put hosts in the grid");
rows.ShouldAllBe(row => row is HostRowViewModel);
});
await MeasureHostsAsync(
faults => faults.ShouldBeEmpty("with every host filed and the group holding them open"));
}
///
/// The narrowest the grid ever gets, and the width the tile was sized against: the window at its
/// minimum, less the nav rail and less the drawer.
///
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerOpen()
{
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
}
///
/// The grid is still a grid at the window's minimum with the drawer open.
///
///
///
/// The harness cannot see this and never will. Its one rule is that a control is inside the
/// window, so a wrap that has quietly collapsed to a single column reports perfectly clean — every card
/// is inside, just one above the other. That is exactly what happened: the tile's width was set from
/// arithmetic that left out the scrolling stack's own margins, and the grid became a list with extra
/// padding at precisely the size this application guarantees.
///
///
/// Two per row rather than a width assertion, because the number that matters is the number of columns.
/// A width is one of the inputs — the margins, the padding and the scrollbar are the others — and
/// pinning the input would go on passing while any of the rest moved.
///
///
[Fact]
public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen()
{
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
await OnTheHostsScreenAsync((screen, window) =>
{
var cards = screen.HostGrid
.GetVisualDescendants()
.OfType()
.Where(item => item.DataContext is HostRowViewModel)
.Select(item => item.TranslatePoint(default, window)
?? throw new InvalidOperationException("a card is not in this window's tree"))
.ToList();
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host in the grid");
cards.GroupBy(point => Math.Round(point.Y))
.Max(row => row.Count())
.ShouldBeGreaterThanOrEqualTo(
2,
"at the window's minimum, with the drawer out, the cards still wrap two to a row");
});
}
///
/// The widest the drawer's own contents get while the grid is beside them: the host editor open, which
/// is what EDIT does to a screen that already has both columns up.
///
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerEditingAHost()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the editor out beside the grid"));
}
// ---- The hosts screen ----
//
// Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal
// moved out from under it, and nothing in that window can be laid out here — so the connect banner, the
// two host key prompts and the conflict log had never been through this harness at all. They are also
// the four worst candidates for that: each appears only in a state somebody has to reproduce by hand.
[Fact]
public async Task TheHostsScreenFitsWithNothingToAnnounce()
{
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("the ordinary shape"));
}
[Fact]
public async Task TheHostsScreenFitsWithAHostSelected()
{
vault.SelectedHost = vault.Hosts[0];
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the overview showing a host"));
}
[Fact]
public async Task TheHostsScreenFitsWhileAHostKeyIsBeingApproved()
{
vault.PendingHostKey = new HostKeyPresentation(
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the unknown-key prompt up"));
}
[Fact]
public async Task TheHostsScreenFitsWhileAHostKeyIsRefused()
{
vault.HostKeyMismatch =
"db.internal:22 presented ssh-ed25519 SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0, "
+ "and this keychain has SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI pinned for it.";
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
}
///
/// Twenty, because one is not the case that broke. The log sits on an Auto row above the overview,
/// and an ItemsControl with no ceiling grows for as long as it has rows — so a pass that merged a
/// vault's worth of items pushed everything below it off the bottom of a screen with nothing to scroll.
/// It survived as long as it did because this markup was inside the window, where no test could reach it;
/// finding it is what the extraction was for. The fix is the ScrollViewer and MaxHeight in
/// HostsScreen.axaml, and this is what holds them there.
///
[Fact]
public async Task TheHostsScreenFitsWithAConflictLogTooLongToShow()
{
for (var i = 0; i < 20; i++)
{
vault.Conflicts.Add(new ConflictRowViewModel(new ConflictNotice(
Guid.CreateVersion7(),
Guid.CreateVersion7(),
ConflictKind.FieldOverridden,
$"'host-{i}' was changed on two machines, and the other machine's value was kept.",
[],
TimeProvider.System.GetUtcNow())));
}
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with twenty merged conflicts to report"));
}
///
/// The group cards are a wrap above the host cards, so more of them than a row holds is the case that
/// pushes the hosts down rather than one that overflows sideways. Six, because that is more than
/// anybody's first three and enough to need a second row at the window's minimum.
///
[Fact]
public async Task TheHostsScreenFitsWithMoreGroupsThanARowHasRoomFor()
{
await SeedGroupsAsync(6);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six group cards above the hosts"));
}
///
/// The question replaces the group's two buttons rather than stacking under them — the same rule every
/// other pair in this application follows — and it is the taller of the two, because it says how many
/// hosts are about to move.
///
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
{
await SeedGroupsAsync(3);
vault.SelectedGroup = vault.Groups[0];
vault.DeleteGroupCommand.Execute(null);
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
}
// ---- The vault screen ----
[Fact]
public async Task TheKeychainScreenFitsInEveryCategory()
{
foreach (var section in new[]
{
VaultSection.All, VaultSection.Keys, VaultSection.Credentials,
VaultSection.Tags, VaultSection.Buckets,
})
{
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 TheKeychainScreenFitsWithTheKeyEditorOpen()
{
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 TheKeychainScreenFitsWithThePasswordEditorOpen()
{
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 generate form, in the 244-pixel detail pane — two algorithm buttons side by side plus two
/// paragraphs of explanation, in the narrowest column in the application. The paragraphs are the risk:
/// they are what says the file has no passphrase, and a sentence pushed off the bottom is a limitation
/// nobody was told about.
///
[Fact]
public async Task TheKeychainScreenFitsWithTheGenerateFormOpen()
{
vault.NewGeneratedKeyCommand.Execute(null);
vault.IsGeneratingKey.ShouldBeTrue();
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
}
///
/// The S3 screen before there is a bucket to open, which is the state every new account starts in and
/// the state whose contents changed: a heading, a paragraph and a button where an empty picker used to
/// be. The paragraph is the risk — it is what says a bucket is a keychain item — and it sits in the
/// 320-pixel invitation column with no scroll viewer above it.
///
[Fact]
public async Task TheS3ScreenFitsWithNoBucketsToOpen()
{
transfers.Remote = RemoteKind.Bucket;
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
}
///
/// Both drop highlights forced on at once, which is a state the screen never actually reaches — the
/// point is that an overlay covering a whole pane does not change the layout of anything beneath it.
/// It cannot check the thing most likely to be wrong, which is IsHitTestVisible="False": an
/// overlay that hit-tests lays out identically and swallows the events that would clear it. That one is
/// in docs/manual-checks.md.
///
[Fact]
public async Task TheTransfersScreenFitsWithTheDropHighlightsShowing()
{
transfers.IsLocalDropTarget = true;
transfers.IsRemoteDropRefused = true;
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
}
// ---- The import screen ----
[Fact]
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
{
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
}
///
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
/// understands and one they think is broken.
///
[Fact]
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
{
await MeasureImportAsync(
faults => faults.ShouldBeEmpty("with a scanned list"),
await ScannedImportAsync());
}
// ---- The host keys screen ----
[Fact]
public async Task TheHostKeysScreenFitsWithNothingApprovedYet()
{
foreach (var pin in vault.KnownHostPins.ToList())
{
await knownHosts.ForgetAsync(pin.Host, pin.Port, Token);
}
await vault.LoadAsync(Token);
vault.KnownHostPins.ShouldBeEmpty();
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("the empty state"));
}
///
/// The shape the column widths were chosen for. A fingerprint is never trimmed — comparing a shortened
/// one against a published one is not something anybody can do — so this table has one column that
/// refuses to give ground, and this is what says the rest still fits beside it.
///
[Fact]
public async Task TheHostKeysScreenFitsWithPinsAndOneSelected()
{
var pins = new KnownHostsViewModel(vault);
pins.VisiblePins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
pins.Selected = pins.VisiblePins[0];
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with a pin selected"), pins);
}
[Fact]
public async Task TheHostKeysScreenFitsWhenTheFilterMatchesNothing()
{
var pins = new KnownHostsViewModel(vault) { Filter = "no such fingerprint" };
pins.VisiblePins.ShouldBeEmpty();
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins);
}
// ---- The connecting card ----
//
// It fills the terminal's own rectangle, which is the one part of this window no other test can lay out:
// the WebView it stands in for cannot be attached here at all. That makes it worth measuring for exactly
// the reason the harness exists — its two buttons are the only way out of a connection that is not
// going to happen.
[Fact]
public async Task TheConnectingCardFitsWhileAConnectionIsBeingMade()
{
await MeasureConnectingAsync(
faults => faults.ShouldBeEmpty("while connecting"),
new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22"));
}
///
/// The taller of the two shapes, and the one with something variable in it: a refusal is whatever the
/// SSH layer said, which is a sentence rather than a word.
///
[Fact]
public async Task TheConnectingCardFitsWithARefusalInIt()
{
var tab = new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22");
tab.Failed(
"Permission denied (publickey,keyboard-interactive). The server closed the connection after "
+ "three attempts.");
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
}
// ---- The logs screen ----
[Fact]
public async Task TheLogsScreenFitsWithNeitherLogWrittenTo()
{
await MeasureLogsAsync(faults => faults.ShouldBeEmpty("the empty state"), LogSection.Connections);
}
///
/// Six columns in one row, and the two widest — an address and a device name — are both variable. A
/// connection still open is measured alongside the finished ones because its row carries the longest
/// value the LASTED column ever holds: the words "still open" rather than a duration.
///
[Fact]
public async Task TheConnectionLogFitsWithALiveRowAndAFinishedOne()
{
var logs = await SeedLogsAsync();
logs.Connections.ShouldNotBeEmpty();
logs.Connections.Any(row => row.IsLive).ShouldBeTrue("the live row is the wide one");
await MeasureLogsAsync(
faults => faults.ShouldBeEmpty("with a live connection above a finished one"),
LogSection.Connections,
logs);
}
///
/// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them —
/// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that
/// touched several, because one field name fits anywhere. The count is stated rather than derived, so
/// it has to be recounted against HostKind.Changes whenever a field is added; the literal in
/// SeedLogsAsync is the thing that actually keeps the column measured at its worst.
///
[Fact]
public async Task TheActivityLogFitsWithAnEditThatTouchedSeveralFields()
{
var logs = await SeedLogsAsync();
logs.Section = LogSection.Activity;
logs.Activity.ShouldNotBeEmpty();
await MeasureLogsAsync(
faults => faults.ShouldBeEmpty("with the keychain log showing"), LogSection.Activity, logs);
}
// ---- The snippets screen ----
[Fact]
public async Task TheSnippetsScreenFitsWithNothingSavedYet()
{
vault.Snippets.ShouldBeEmpty("the seed makes none, which is what a new keychain looks like");
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("the empty state"));
}
///
/// The detail pane's longest shape: a multi-line command in a box, its notes, two buttons and the
/// paragraph saying what a terminal will do with it — in a 300-pixel column. Measured with a snippet
/// that runs, because that is the one with the extra button.
///
[Fact]
public async Task TheSnippetsScreenFitsWithAMultiLineSnippetSelected()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen(new InsertTarget(1, "prod-db"));
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with a running snippet selected"), snippets);
}
///
/// The editor, which is the tallest thing on this screen: a name, a 140-pixel command box, notes, the
/// checkbox and the paragraph explaining what leaving it off buys.
///
[Fact]
public async Task TheSnippetsScreenFitsWithItsEditorOpen()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
snippets.Selected = snippets.Visible[0];
snippets.EditCommand.Execute(null);
snippets.IsEditing.ShouldBeTrue();
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
}
[Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
snippets.Filter = "no such command";
snippets.Visible.ShouldBeEmpty();
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), snippets);
}
///
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
/// application.
///
[Fact]
public async Task TheKeychainScreenFitsWithADeletionInQuestion()
{
var keyId = vault.Keys[0].EntityId;
foreach (var host in vault.Hosts.Take(4).ToList())
{
vault.SelectedHost = host;
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.Kind is AuthenticationKind.SshKey && choice.EntityId == keyId);
await vault.SaveHostCommand.ExecuteAsync(null);
}
vault.Section = VaultSection.Keys;
vault.SelectedVaultItem = vault.VaultItems.Single(row => row.EntityId == keyId);
vault.DeleteSelectedItemCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().HasUsage
.ShouldBeTrue("four bound hosts are what makes this the long shape");
await OnTheVaultAsync((screen, window) =>
{
LayoutHarness.Unreachable(window).ShouldBeEmpty();
// And it says something. A card whose bindings did not resolve would lay out perfectly as three
// empty rows, which is the one failure a fit test cannot see: compiled bindings against the
// wrong data type are a logged message rather than an exception.
var card = screen.GetVisualDescendants().OfType().ShouldHaveSingleItem();
var said = string.Join(
" ",
card.GetVisualDescendants().OfType().Select(text => text.Text));
said.ShouldContain("key-0", Case.Insensitive, "the question has to name what is going");
said.ShouldContain("4 hosts authenticate with it");
said.ShouldContain("no undo");
});
}
///
/// 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