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 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()); } /// /// Headings are rows in the same list as the hosts, drawn from a different template, and they are the /// widest thing in a 268-pixel column: a name, a chevron and a count on one line. Measured with one group /// folded, because a folded heading is the shape whose row is on screen without any of its hosts. /// [Fact] public async Task TheHostSidebarFitsWithGroupHeadingsInTheList() { await SeedGroupsAsync(3); vault.SidebarRows.OfType().Count() .ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped"); vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType().First()); await MeasureSidebarAsync(faults => faults.ShouldBeEmpty("with three headings and one folded")); } /// /// /// 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 keychain any more", Case.Insensitive, "the double-click has to reach the connect command"); }); } // ---- 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 panel is a row of its own at the foot of this screen, so it competes with the overview above /// it for the same column — and it grows sideways as groups are added, which is the direction a /// fixed-width column has least of. Six, because that is more than anybody's first three and enough to /// need the horizontal scroller rather than to overflow silently. /// [Fact] public async Task TheHostsScreenFitsWithMoreGroupsThanTheRowHasRoomFor() { await SeedGroupsAsync(6); await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six groups along the bottom")); } /// /// The question replaces the buttons rather than stacking under them — the same rule the sidebar's own /// deletion 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 TheVaultScreenFitsInEveryCategory() { foreach (var section in new[] { VaultSection.All, VaultSection.Keys, VaultSection.Credentials, }) { 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 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 TheVaultScreenFitsWithTheGenerateFormOpen() { vault.NewGeneratedKeyCommand.Execute(null); vault.IsGeneratingKey.ShouldBeTrue(); await MeasureVaultAsync(faults => faults.ShouldBeEmpty()); } /// /// 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 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 eleven of them. /// Measured with an edit that touched several, because one field name fits anywhere. /// [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 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