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