using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.Threading; using Avalonia.VisualTree; using DodoSSH.Client.Shell.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!; /// /// 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 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 strip along the sidebar's bottom edge with the question in it instead 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 — in a 268-pixel column whose middle is a list that has already taken every spare pixel. /// /// /// Worth measuring rather than assuming, because this is the one card in the application a user cannot /// scroll: the sidebar's only ScrollViewer is inside the host list, so a button pushed past the /// bottom edge here would leave the question unanswerable in either direction. /// /// [Fact] public async Task TheHostSidebarFitsWithADeletionInQuestion() { vault.SelectedHost = vault.Hosts[0]; vault.SelectedHost.IsConnected = true; vault.DeleteHostCommand.Execute(null); vault.IsConfirmingDeletion.ShouldBeTrue(); vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape"); await MeasureSidebarAsync(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 OnTheSidebarAsync((sidebar, window) => { var row = sidebar.HostList.GetVisualDescendants() .OfType() .First(); var centre = row.TranslatePoint( new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window) ?? throw new InvalidOperationException("the row 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 row selects it"); vault.Status.ShouldContain( "not in this vault any more", Case.Insensitive, "the double-click has to reach the connect command"); }); } // ---- 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 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 TheVaultScreenFitsWithADeletionInQuestion() { 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