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.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!; 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); await SeedAsync(); } /// public async ValueTask 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