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.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;

/// <summary>
/// Whether each screen fits in the space the window gives it.
/// </summary>
/// <remarks>
/// <para>
/// This suite used to measure one control, <c>VaultColumn</c>, 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
/// <c>MainWindow</c> still cannot be laid out here at all, because WebView2's adapter refuses the headless
/// dispatcher's MTA thread. <see cref="LayoutHarnessTests"/> pins that.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// A real <c>VaultViewModel</c> 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.
/// </para>
/// </remarks>
public sealed class ScreenLayoutTests : IAsyncLifetime
{
    private const string Passphrase = "a sufficiently long passphrase";
    private const string ServerUrl = "https://dodossh.example";

    /// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
    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!;

    /// <remarks>
    /// 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.
    /// </remarks>
    private MainWindowViewModel shell = null!;

    /// <remarks>
    /// 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.
    /// </remarks>
    private TransfersViewModel transfers = null!;

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    /// <inheritdoc />
    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<string, TerminalAsset>(StringComparer.Ordinal)),
            Substitute.For<ISshConnectionFactory>(),
            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<ISftpSessionFactory>(),
            CheapProfile);

        transfers = new TransfersViewModel(
            Substitute.For<ISftpSessionFactory>(), 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);
    }

    /// <inheritdoc />
    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. v5 widened it again, to 320, for the ADDRESS field's own breathing room; see
    // LayoutHarness.HostDrawerWidth.

    /// <remarks>
    /// 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
    /// <c>VaultViewModel.IsHostPaneOpen</c>.
    /// </remarks>
    [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());
    }

    /// <remarks>
    /// 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 relay card, QUICK ACCESS's own rows and its add row, a
    /// paragraph of hint text and three buttons.
    ///
    /// A pin is staged so QUICK ACCESS draws at least one row rather than only its empty add box — the
    /// harness skips the scrolled content's height (see the remark above
    /// <see cref="TheHostDrawerFitsWithADeletionInQuestion"/>) but not its width, and a row's own mono path
    /// is the one place in this card long text could push the column sideways if TextTrimming ever slipped.
    /// </remarks>
    [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);

        vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
        vault.AddEditorPinCommand.Execute(null);

        await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// The editor with its new-credential card showing, which the test above never draws: the card is
    /// collapsed until somebody presses + NEW CREDENTIAL, so nothing else in this suite measures the three
    /// boxes, the paragraph of hint text and the two buttons it adds inside the section that already holds
    /// the authentication picker. A card that only appears on a click is exactly the shape that escapes a
    /// harness driven by the default state.
    /// </remarks>
    [Fact]
    public async Task TheHostDrawerFitsWithTheNewCredentialFormOpen()
    {
        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.BeginEditorCredentialCommand.Execute(null);
        vault.IsAddingEditorCredential.ShouldBeTrue("there is nothing to measure otherwise");

        await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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());
    }

    /// <remarks>
    /// <para>
    /// 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.
    /// </para>
    /// <para>
    /// Still worth measuring although the drawer scrolls as a whole now — see <c>HostDrawer.axaml</c> — and
    /// the reason has changed rather than gone. The harness skips anything inside a <c>ScrollViewer</c>, 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.
    /// </para>
    /// </remarks>
    [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());
    }

    /// <remarks>
    /// <para>
    /// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
    /// heading, a combo box, a wrapping paragraph and two buttons, in a 320-pixel column. The paragraph is
    /// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
    /// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
    /// </para>
    /// <para>
    /// The state is set here rather than through <c>MoveHostCommand</c>, which would refuse: this fixture's
    /// account holds one vault, and the command declines rather than open a picker with nothing in it. What
    /// this test is about is the rectangle, and the flow that fills it is covered in
    /// <c>DodoSSH.Client.App.Tests</c>.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostDrawerFitsWithTheMovePanelOpen()
    {
        vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);

        vault.MoveVaultChoices.Add(
            new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));

        vault.SelectedMoveVault = vault.MoveVaultChoices[0];
        vault.IsMovingHost = true;

        vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it");

        await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// <para>
    /// 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.
    /// </para>
    /// <para>
    /// 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 <c>TryBuildAuthentication</c> 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.
    /// </para>
    /// </remarks>
    [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.GetVisualDescendants()
                .OfType<ListBoxItem>()
                .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");
        });
    }

    /// <remarks>
    /// <para>
    /// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> 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.
    /// </para>
    /// <para>
    /// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
    /// A <c>ListBox</c> 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.
    /// </para>
    /// <para>
    /// 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.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostsScreensKeyboardTargetTakesFocusInBothOfItsShapes()
    {
        await OnTheHostsScreenAsync((screen, _) =>
        {
            screen.KeyboardTarget.ShouldBeSameAs(screen.Board);
            screen.KeyboardTarget.Focus().ShouldBeTrue("the board has cards on it");
        });

        vault.HostFilter = "nothing matches this";
        vault.HasHostBoardEntries.ShouldBeFalse();

        await OnTheHostsScreenAsync((screen, _) =>
        {
            screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
            screen.KeyboardTarget.Focus().ShouldBeTrue("the board is empty, so the find box takes it");
        });
    }

    /// <remarks>
    /// The editor open with the board 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.
    /// </remarks>
    [Fact]
    public async Task TheHostsScreensKeyboardTargetStillTakesFocusWithTheEditorOpen()
    {
        vault.NewHostCommand.Execute(null);

        await OnTheHostsScreenAsync((screen, _) =>
        {
            screen.Board.IsEffectivelyVisible.ShouldBeTrue();
            screen.KeyboardTarget.Focus().ShouldBeTrue();
        });
    }

    /// <summary>
    /// v5: each section's own card list holds cards and nothing else — the heading is a sibling, not a row.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This used to hold for one flat grid: no group headings were mixed into the host rows, because the
    /// group cards above it were where a group's name was drawn. The board replaces that grid with one
    /// section per group — see <c>VaultViewModel.HostSections</c> — and the invariant moves down a level
    /// with it: it is each section's own <c>ListBox</c> (<c>Classes="sectioncards"</c>) that must hold only
    /// <see cref="HostRowViewModel"/>s, because <c>HostsScreen.axaml.cs</c> finds every card by walking every
    /// list wearing that class and would misfire on a heading it found mixed in.
    /// </para>
    /// <para>
    /// Measured with every host filed under one group, which is the shape that puts the most rows behind one
    /// heading.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task EachSectionsCardListHoldsCardsAndNothingElse()
    {
        await SeedGroupsAsync(3);

        // Filed through the host editor, same as SeedGroupsAsync itself does — the drag this used to exercise
        // left with the group cards in v5; see VaultViewModel.EditGroup's own remarks.
        foreach (var host in vault.Hosts.ToArray())
        {
            vault.SelectedHost = host;
            vault.EditSelectedHostCommand.Execute(null);

            vault.EditorSelectedGroup = vault.EditorGroupChoices
                .First(choice => choice.EntityId == vault.Groups[0].EntityId);

            await vault.SaveHostCommand.ExecuteAsync(null);
        }

        await OnTheHostsScreenAsync((screen, _) =>
        {
            var rows = screen.GetVisualDescendants()
                .OfType<ListBox>()
                .Where(list => list.Classes.Contains("sectioncards"))
                .SelectMany(list => list.GetVisualDescendants().OfType<ListBoxItem>())
                .Select(item => item.DataContext)
                .ToList();

            rows.ShouldNotBeEmpty("the seed has to put hosts in one of the sections");
            rows.ShouldAllBe(row => row is HostRowViewModel);
        });

        await MeasureHostsAsync(
            faults => faults.ShouldBeEmpty("with every host filed under one group and four sections drawn"));
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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"));
    }

    /// <summary>
    /// The grid is still a grid at the window's minimum with the drawer open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The harness cannot see this and never will.</b> 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.
    /// </para>
    /// <para>
    /// 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.
    /// </para>
    /// </remarks>
    [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.GetVisualDescendants()
                .OfType<ListBoxItem>()
                .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 on the board");

            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");
        });
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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.
    //
    // The host key prompts have since left this screen for a card over the whole surface, and their two tests
    // went with them; see the host key decision below.

    [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"));
    }

    // ---- The host key decision ----
    //
    // ◆ These two were TheHostsScreenFits… tests, because the prompts were banners at the top of that screen
    // and the shell navigated there before either could be raised. They are a card over the whole surface
    // now — see HostKeyCard.axaml — so they are measured in the rectangle a card gets rather than in a
    // screen's rows, and they moved rather than being rewritten: the shapes worth measuring are the same two,
    // and each still only appears in a state somebody has to reproduce by hand.

    /// <remarks>
    /// A full fingerprint is the widest line here and it must not be trimmed — the whole point of the card is
    /// that somebody can compare it character by character against what an operator published, and an
    /// ellipsis in the middle of one is worse than a card that does not fit, because it looks correct.
    /// </remarks>
    [Fact]
    public async Task TheHostKeyCardFitsWhileAKeyIsBeingApproved()
    {
        vault.PendingHostKey = new HostKeyPresentation(
            "db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");

        await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the unknown-key decision up"));
    }

    /// <remarks>
    /// The taller of the two, and the one whose height is not this control's to choose: the explanation is
    /// composed by the view model out of a host, a port and two full fingerprints, so it wraps to several
    /// lines and grows with the length of a hostname.
    /// </remarks>
    [Fact]
    public async Task TheHostKeyCardFitsWhileAKeyIsRefused()
    {
        vault.HostKeyMismatch =
            "The host key for db.production.internal:22 has changed. "
            + "Pinned SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI, but the server offered "
            + "SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0.";

        await MeasureHostKeyAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
    }

    /// <remarks>
    /// Twenty, because one is not the case that broke. The log sits on an <c>Auto</c> row above the overview,
    /// and an <c>ItemsControl</c> 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 <c>ScrollViewer</c> and <c>MaxHeight</c> in
    /// <c>HostsScreen.axaml</c>, and this is what holds them there.
    /// </remarks>
    [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"));
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWithMoreGroupsThanARowHasRoomFor()
    {
        await SeedGroupsAsync(6);

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six group cards above the hosts"));
    }

    /// <remarks>
    /// The question opens under the GROUPS heading and pushes the cards down, and it is the tallest thing
    /// this section draws: a heading, a consequence, a boxed count, and now a tick with a sentence beside it
    /// asking whether the machines go too. The tick is the part worth measuring, because it is a wrapping
    /// paragraph inside a control whose own height the layout does not obviously account for.
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
    {
        await SeedGroupsAsync(3);

        // Passed directly, the way DeleteGroupFromHeading resolves and hands in a row now that there is no
        // group-card selection to fall back on.
        vault.DeleteGroupCommand.Execute(vault.Groups[0]);

        vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
        vault.PendingDeletion.ShouldNotBeNull().HasChoice
            .ShouldBeTrue("the hosts filed under it are what makes this the long shape");

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
    }

    /// <remarks>
    /// <para>
    /// The group's move panel, under the GROUPS heading beside the deletion question and the wordier of the
    /// two: a heading, a combo box, a wrapping paragraph naming everything that travels and everything that
    /// does not, and two buttons — above a wrap of group cards and the host grid, all of which still have to
    /// fit under it.
    /// </para>
    /// <para>
    /// The state is set here rather than through <c>MoveGroupCommand</c>, which would refuse: this fixture's
    /// account holds one vault, and the command declines rather than open a picker with nothing in it. The
    /// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWithTheGroupMovePanelOpen()
    {
        await SeedGroupsAsync(3);

        vault.MoveGroupVaultChoices.Add(
            new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));

        vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0];
        vault.IsMovingGroup = true;

        vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment");

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
    }

    // ---- ◆ The hosts screen with a set of cards ticked ----
    //
    // Ctrl, Shift and a band put the phone's chosen-hosts set on this screen — see HostsScreen.axaml.cs — and
    // with it come one strip and three panels that had never been drawn in a window. All four sit between the
    // HOSTS heading and the grid, so every one of them shortens the grid rather than overflowing it; that is
    // the property these measure. The gestures themselves are HostGridTests'.

    /// <remarks>
    /// The strip: a count, CLEAR, and the sentence saying the actions are on the menu — squeezed between the
    /// heading and the grid's own count on the same row. It is the one thing on this screen whose width is
    /// set by nothing but its text, so what is really being measured is that the sentence trims instead of
    /// running out over the number at the far end. Measured with the drawer open, which is the width at which
    /// it does not fit and has to.
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWhileCardsAreTicked()
    {
        vault.ChooseHostCommand.Execute(vault.Hosts[0]);
        vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);

        vault.IsChoosingHosts.ShouldBeTrue("the strip is only drawn while something is ticked");

        vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
        vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with two cards ticked"));
    }

    /// <summary>
    /// The tallest of the three panels: the vault picker with the key question under it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A heading, a picker, a wrapping paragraph naming everything that travels and everything that does not,
    /// a tick with a second wrapping sentence beside it, and two buttons — all above the group cards and the
    /// grid, which still have to fit under it.
    /// </para>
    /// <para>
    /// The host is given a key first, because the tick is drawn only for a move of exactly one host that has
    /// something to bring; without that this would measure the short shape and say the long one fits. The
    /// panel is opened by hand rather than through <c>MoveChosenHostsToVault</c> for the reason the group
    /// move test gives: this fixture's account holds one vault, and the command declines rather than open a
    /// picker with nothing in it.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWithTheChosenHostsVaultPanelOpen()
    {
        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
            .First(choice => choice.Kind is AuthenticationKind.SshKey);

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.ChooseHostCommand.Execute(vault.Hosts[0]);

        vault.ChosenHostVaultChoices.Add(
            new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));

        vault.SelectedChosenHostVault = vault.ChosenHostVaultChoices[0];
        vault.IsSendingChosenHostsToAVault = true;

        vault.HasAChosenBindingToBring
            .ShouldBeTrue("the key question is the part of this panel worth measuring");

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's vault panel up"));
    }

    /// <remarks>
    /// The group picker over the set, which is the same panel the phone draws and the same write a set
    /// dragged onto a group card makes. Shorter than the vault panel above and drawn in the same place, so
    /// what this adds is the picker being filled from one keychain's groups rather than from nothing.
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen()
    {
        await SeedGroupsAsync(3);

        vault.ChooseHostCommand.Execute(vault.Hosts[0]);
        vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
        vault.ChosenHostGroupChoices.Count.ShouldBeGreaterThan(1, "no group, and the three seeded ones");

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's group panel up"));
    }

    /// <remarks>
    /// One question for the whole set, drawn by the same card the group deletion above uses. The count is
    /// what makes it a confirmation somebody reads rather than one they press past, and the consequence line
    /// wraps — which is the part a narrower column would push out of the window.
    /// </remarks>
    [Fact]
    public async Task TheHostsScreenFitsWhileTheChosenHostsAreBeingDeleted()
    {
        vault.ChooseHostCommand.Execute(vault.Hosts[0]);
        vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);

        vault.DeleteChosenHostsCommand.Execute(null);

        vault.IsConfirmingChosenHostDeletion.ShouldBeTrue("the question has to be up for this to measure it");

        await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's deletion 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"));
        }
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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());
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [Fact]
    public async Task TheKeychainScreenFitsWithTheGenerateFormOpen()
    {
        vault.NewGeneratedKeyCommand.Execute(null);
        vault.IsGeneratingKey.ShouldBeTrue();

        await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [Fact]
    public async Task TheS3ScreenFitsWithNoBucketsToOpen()
    {
        transfers.Remote = RemoteKind.Bucket;

        transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");

        // The buckets budget, not the full session shell's: MainWindow.axaml gives S3 the same TransfersScreen
        // control inside a padded, bordered container but with no tab row, no header, no status bar and no
        // sidebar around it — see its own remark on why the S3 usage is "deliberately not given the full
        // session shell above."
        await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
    }

    /// <remarks>
    /// 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 <c>IsHitTestVisible="False"</c>: an
    /// overlay that hit-tests lays out identically and swallows the events that would clear it. That one is
    /// in docs/manual-checks.md.
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithTheDropHighlightsShowing()
    {
        transfers.IsLocalDropTarget = true;
        transfers.IsRemoteDropRefused = true;

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
    }

    // v5c-3: the import screen's own layout coverage moved to SettingsPagesLayoutTests — it is a settings
    // page now, drawn inside settings mode over the Preferences page rather than beside the ordinary nav
    // rail; see MainWindowViewModel.IsImportOpen and design-notes/v5c-fidelity-notes.md.

    // ---- 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"));
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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"));
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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);
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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);
    }

    /// <remarks>
    /// 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 <c>HostKind.Changes</c> whenever a field is added; the literal in
    /// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
    /// </remarks>
    [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"));
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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);
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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);
    }

    /// <remarks>
    /// <para>
    /// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
    /// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
    /// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
    /// </para>
    /// <para>
    /// The state is set here rather than through <c>MoveCommand</c>, which would refuse: this fixture's
    /// account holds one vault, and the command declines rather than open a picker with nothing in it. The
    /// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
    /// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
    {
        await SeedSnippetsAsync();

        var snippets = NewSnippetsScreen();
        snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);

        snippets.MoveVaultChoices.Add(
            new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));

        snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
        snippets.IsMoving = true;

        snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");

        await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel 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);
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [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<ConfirmDeleteCard>().ShouldHaveSingleItem();

            var said = string.Join(
                " ",
                card.GetVisualDescendants().OfType<TextBlock>().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");
        });
    }

    /// <remarks>
    /// 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.
    /// </remarks>
    [Fact]
    public async Task TheCategoryRailIsBigEnoughToClick()
    {
        await OnTheVaultAsync((screen, _) =>
        {
            var buttons = screen.GetVisualDescendants()
                .OfType<Button>()
                .Where(button => button.Classes.Contains("cat"))
                .ToList();

            buttons.Count.ShouldBe(5, "one per category that exists");

            foreach (var button in buttons)
            {
                button.Bounds.Height.ShouldBeGreaterThan(20);
                button.Bounds.Width.ShouldBeGreaterThan(120);
            }
        });
    }

    // ---- The transfers screen ----
    //
    // ◆ WAVE C's OWN BUDGET. This screen carries two real widths, not one, because the same TransfersScreen
    // control sits in two different real containers — see LayoutHarness.SessionScreenWidth's own remark and
    // MainWindow.axaml. On SFTP it is inside wave B's session shell: a 26px padded column, a 1px bordered
    // container, and — once Transfers.IsConnected — a 300px QUICK ACCESS sidebar squeezed in beside it. On S3
    // it is the plain screen it always was, at the roomier LayoutHarness.ScreenWidth/ScreenHeight budget; see
    // TheS3ScreenFitsWithNoBucketsToOpen and MeasureBucketsAsync. Every test below this point measures the
    // SFTP usage through MeasureTransfersAsync, which reads transfers.IsConnected itself to decide whether
    // the sidebar is squeezing the pane — the same fact ShowsQuickAccessSidebar reads.

    /// <remarks>
    /// <para>
    /// The widest this screen gets and the one with the least room to give once connected: two file listings
    /// side by side, a 64-pixel arrow column between them, and a queue underneath — inside 472 pixels once
    /// the session shell's own padding, border and QUICK ACCESS sidebar have all been taken out. That leaves
    /// 204 pixels a side, which is the width every restyled row template in <c>TransfersScreen.axaml</c> was
    /// actually chosen against; see its own remark on the column widths.
    /// </para>
    /// <para>
    /// Measured disconnected here, though, which is the state the screen opens in and the one where the
    /// local pane is at its fullest — it lists this machine's home directory, so the row template is
    /// exercised with real names of real length rather than with fixtures chosen to fit. Disconnected also
    /// means no sidebar yet, so this particular test is measured at the roomier 772-pixel shape;
    /// <see cref="TheTransfersScreenFitsWithASessionOpen"/> below is the one that reaches the 472-pixel one.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsBeforeAnythingIsConnected()
    {
        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// <para>
    /// The queue is the half of this screen that only exists once something has been asked for, so a shape
    /// nothing puts a row into is a shape never laid out. Three rows, because the row template changes with
    /// the state: a running one shows a bar and a STOP, a stopped one shows RESUME and DISCARD, and a failed
    /// one carries the server's own sentence in the column the other two put a byte count in.
    /// </para>
    /// <para>
    /// The rows are placed directly rather than driven through the queue. What is being measured is the
    /// template at each state, and running a real transfer to reach those states would put a thread-pool
    /// hand-off and a filesystem in the middle of a test about rectangles. What the queue does is measured in
    /// <c>DodoSSH.Client.Transfer.Tests</c>.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithTransfersInTheQueue()
    {
        Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
            TransferState.Running, bytesPerSecond: 6_500_000);

        Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
            TransferState.Cancelled);

        Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed,
            failure: "deploy.sh is already in that directory on the host. Rename or remove it first — "
                + "nothing here overwrites a file that is already there.");

        transfers.Transfers.Count.ShouldBe(3);

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// <para>
    /// ◆ The tallest the invitation gets, and the shape the connect bar's removal has to survive. Everything
    /// that used to be a strip across the top of the screen is now stacked in the right-hand pane — a
    /// picker, a password box and two buttons, under a heading — and that pane is only as tall as whatever
    /// the queue leaves it.
    /// </para>
    /// <para>
    /// So the queue is filled first, which is what takes it to its 196-pixel maximum and leaves the panes
    /// their least. Without those rows this measures the invitation with nearly twice the room it is
    /// guaranteed, which is the version of this test that would pass whatever was added to the panel.
    /// </para>
    /// <para>
    /// No host is selected on purpose: a picker with nothing chosen shows the password box, because a host
    /// that names neither a key nor a credential is one that will ask for a password. That is the shape with
    /// the extra row in it.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithTheHostPickerOpenOverAFullQueue()
    {
        Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
            TransferState.Running, bytesPerSecond: 6_500_000);

        Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
            TransferState.Cancelled);

        Enqueue(TransferDirection.Upload, "deploy.sh", 4_096, 0, TransferState.Failed);

        transfers.BeginChoosingRemoteCommand.Execute(null);

        transfers.IsChoosingRemote.ShouldBeTrue();
        transfers.SelectedHostAsksForAPassword
            .ShouldBeTrue("the password box is what makes this the tall shape");

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with the picker open"));
    }

    /// <remarks>
    /// The bucket half of the picker, which differs by one row: no password box, because an object store
    /// carries its keys in the vault and has nothing to type. Measured because the two are separate markup
    /// rather than one picker with its items swapped — see the note on <c>RemoteKind</c>.
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithTheBucketPickerOpen()
    {
        transfers.Remote = RemoteKind.Bucket;
        transfers.BeginChoosingRemoteCommand.Execute(null);

        transfers.ShowsBucketPicker.ShouldBeTrue();
        transfers.SelectedHostAsksForAPassword.ShouldBeFalse("a bucket never asks for one");

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with the bucket picker open"));
    }

    /// <remarks>
    /// <para>
    /// The state the screen is in once something is open, and the tightest one wave C's restyle has to
    /// survive: <c>Transfers.IsConnected</c> is exactly what pulls <c>SessionSidebar</c> into view beside
    /// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
    /// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
    /// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
    /// share the row with it moved out, because the session shell already prints the same address beside
    /// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
    /// thing squeezing DISCONNECT off the edge at this width.
    /// </para>
    /// <para>
    /// The address is a long one deliberately, because <c>SessionAddress</c> still has to hold it without
    /// trimming where the header prints it.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithASessionOpen()
    {
        transfers.IsConnected = true;
        transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
        transfers.Status = "Connected to releases-eu.";

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a session open"));
    }

    /// <remarks>
    /// The trust card covers the whole screen, and it is the one thing here a user cannot get past without
    /// pressing something — so a button of its own that fell outside the window would leave the screen
    /// permanently blocked.
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithTheHostKeyCardShowing()
    {
        transfers.PendingHostKey = new HostKeyPresentation(
            "db.internal", 22, "ssh-ed25519", "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG");

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
    }

    /// <remarks>
    /// The question in front of deleting something on the host, which takes a row out of the remote pane's
    /// column while the listing under it is still showing. A directory, because that is the longer of the
    /// two warnings, and a path deep enough to wrap in a pane a third of the window wide.
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreenFitsWithADeletionInQuestion()
    {
        transfers.PendingRemoteDeletion = new RemoteDeletionRequest(
            "2026-07-30",
            "/srv/releases/site/backups/nightly/2026-07-30",
            IsDirectory: true);

        await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
    }

    // ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----

    /// <remarks>
    /// The row template's own SIZE and MODIFIED columns, proved against real bindings rather than against
    /// the fixture's own home directory — whose contents this suite does not control — by adding one row of
    /// each pane's own shape directly and reading the rendered <c>TextBlock</c>s back. PERMS stays remote-only,
    /// which is what <see cref="RemoteEntryRowViewModel"/> carrying it and <see cref="LocalEntryRowViewModel"/>
    /// not proves alongside the two shared columns.
    /// </remarks>
    [Fact]
    public async Task TheRowTemplateShowsSizeAndDateColumns()
    {
        var local = new LocalEntryRowViewModel(new LocalEntry(
            "notes.txt",
            Path.Combine(Path.GetTempPath(), "notes.txt"),
            IsDirectory: false,
            Length: 4_096,
            new DateTimeOffset(2026, 7, 21, 9, 0, 0, TimeSpan.Zero)));

        // Cleared first, not merely appended to: this fixture's real home directory can hold far more entries
        // than the pane's own viewport, and a virtualizing ListBox only realises the rows that fit in it. A
        // row added after all of those would never be built at all, which is a false pass rather than a
        // proof — the assertion below would find nothing to have missed.
        transfers.LocalEntries.Clear();
        transfers.LocalEntries.Add(local);

        var remote = new RemoteEntryRowViewModel(new SftpEntry(
            "deploy.log",
            "/srv/releases/deploy.log",
            SftpEntryKind.File,
            49_152,
            new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.Zero),
            "-rw-r--r--"));

        transfers.RemoteEntries.Add(remote);

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new TransfersScreen { DataContext = transfers };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);

                try
                {
                    var texts = screen.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text).ToList();

                    texts.ShouldContain(local.Size, customMessage: "the local pane's own SIZE column");
                    texts.ShouldContain(local.Modified, customMessage: "the local pane's own MODIFIED column");
                    texts.ShouldContain(remote.Size, customMessage: "the remote pane's own SIZE column");
                    texts.ShouldContain(remote.Modified, customMessage: "the remote pane's own MODIFIED column");
                    texts.ShouldContain(remote.Permissions, customMessage: "PERMS, which stays remote-only");
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    /// <remarks>
    /// <see cref="TransferRowViewModel.StatusWord"/> is what v5b's TRANSFERS strip prints in place of the old
    /// all-caps <c>StateLabel</c> chip — a live percentage while running, a plain word otherwise — and this
    /// holds the mapping against every state <c>FileTransferQueue</c> actually reports, at the view model and
    /// rendered onto the strip itself.
    /// </remarks>
    [Fact]
    public async Task TheTransferStripsStatusWordsMapTheQueuesRealStates()
    {
        Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
        Enqueue(TransferDirection.Download, "running.bin", 1_000, 640, TransferState.Running, bytesPerSecond: 1_000);
        Enqueue(TransferDirection.Upload, "done.txt", 1_000, 1_000, TransferState.Completed);
        Enqueue(TransferDirection.Upload, "stopped.txt", 1_000, 200, TransferState.Cancelled);
        Enqueue(TransferDirection.Upload, "failed.txt", 1_000, 0, TransferState.Failed, failure: "refused");

        var expected = new[] { "queued", "64%", "done", "stopped", "failed" };

        transfers.Transfers.Select(row => row.StatusWord).ShouldBe(expected);

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new TransfersScreen { DataContext = transfers };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);

                try
                {
                    var words = screen.GetVisualDescendants()
                        .OfType<TextBlock>()
                        .Where(text => text.Classes.Contains("transferstatus"))
                        .Select(text => text.Text ?? string.Empty)
                        .ToList();

                    words.ShouldBe(expected);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    /// <remarks>
    /// The TRANSFERS strip's own "collapse" — not to zero, but down to the honest sentence the full strip
    /// carried when nothing had ever been queued. Both shapes are checked: the header and its count chip are
    /// absent with nothing queued, and a queued transfer brings the full strip straight back.
    /// </remarks>
    [Fact]
    public async Task TheTransfersStripCollapsesWhenTheQueueIsEmpty()
    {
        transfers.Transfers.ShouldBeEmpty("the fixture queues nothing before this test adds any");

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new TransfersScreen { DataContext = transfers };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);

                try
                {
                    var label = screen.GetVisualDescendants()
                        .OfType<TextBlock>()
                        .Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");

                    label.IsEffectivelyVisible.ShouldBeFalse("the full strip collapses with nothing queued");

                    var collapsed = screen.GetVisualDescendants()
                        .OfType<TextBlock>()
                        .Single(text => text.Text is { } spoken
                            && spoken.StartsWith("Nothing queued.", StringComparison.Ordinal));

                    collapsed.IsEffectivelyVisible.ShouldBeTrue(
                        "the honest sentence stays reachable in the collapsed shape");

                    LayoutHarness.Unreachable(window).ShouldBeEmpty();
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

        Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new TransfersScreen { DataContext = transfers };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);

                try
                {
                    var label = screen.GetVisualDescendants()
                        .OfType<TextBlock>()
                        .Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");

                    label.IsEffectivelyVisible.ShouldBeTrue("a queued transfer brings the full strip back");
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    /// <remarks>
    /// <para>
    /// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
    /// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
    /// listing carrying every colour state a row can show (a directory, an executable, a world-writable
    /// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
    /// pane.
    /// </para>
    /// <para>
    /// Both pane headers carry a deep path on purpose, and the paths are set here rather than left to the
    /// fixture's real filesystem. The header's ellipsis only works because the path sits alone in a star
    /// column — see the pane header remark in <c>TransfersScreen.axaml</c> — and the regression it guards
    /// against armed itself only on a machine whose home directory happened to be long: CI's per-job HOME
    /// found it, every developer machine's short profile path missed it. A pinned sixty-character path asks
    /// the question on every machine alike.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
    {
        transfers.IsConnected = true;
        transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
        transfers.LocalPath = "/home/deployment-service/.cache/build/workspaces/site/artefacts";
        transfers.RemotePath = "/srv/releases/site/shared/uploads/production/2026/08/nightly";

        transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
            "docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
            3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
        transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
            "deploy.sh", "/srv/releases/site/deploy.sh", SftpEntryKind.File,
            912, TimeProvider.System.GetUtcNow(), "-rwxr-xr-x")));
        transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
            "shared", "/srv/releases/site/shared", SftpEntryKind.Directory,
            0, TimeProvider.System.GetUtcNow(), string.Empty)));

        Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
            TransferState.Running, bytesPerSecond: 6_500_000);
        Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
            TransferState.Cancelled);

        await MeasureTransfersAsync(
            faults => faults.ShouldBeEmpty("connected, with populated rows and a full queue at once"));
    }

    // ---- The chrome ----

    /// <remarks>
    /// <para>
    /// The two constants the whole height budget is subtracted from, held against the markup that declares
    /// them. If either bar grows, every screen gets less room than this suite thinks it does and every
    /// measurement above quietly becomes optimistic.
    /// </para>
    /// <para>
    /// Laid out with no data context, which is the point: these are fixed-height strips and their geometry
    /// must not depend on what is bound into them. A binding that made one of them grow with its contents
    /// would fail here.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheChromeIsTheHeightTheBudgetAssumes()
    {
        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var titleBar = new TitleBar();
                var titleWindow = LayoutHarness.HostAtMinimumSize(
                    titleBar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);

                try
                {
                    titleBar.Bounds.Height.ShouldBe(LayoutHarness.TitleBarHeight);
                    LayoutHarness.Unreachable(titleWindow).ShouldBeEmpty();
                }
                finally
                {
                    titleWindow.Close();
                }

                var statusBar = new StatusBar();
                var statusWindow = LayoutHarness.HostAtMinimumSize(
                    statusBar, LayoutHarness.MinimumWidth, LayoutHarness.StatusBarHeight);

                try
                {
                    statusBar.Bounds.Height.ShouldBe(LayoutHarness.StatusBarHeight);
                }
                finally
                {
                    statusWindow.Close();
                }
            },
            Token);
    }

    /// <remarks>
    /// <para>
    /// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
    /// than one: the switcher's three segments, each a third of the rail's own content width; the five item
    /// rows below it and the user chip at the foot, both the rail's full content width. A single
    /// across-the-board width assertion the way the v3 version of this test made one would either be wrong
    /// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
    /// own width now.
    /// </para>
    /// <para>
    /// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
    /// five rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
    /// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
    /// entry silently dropping off the bottom would still pass every other assertion here.
    /// </para>
    /// <para>
    /// Five and not six since Pins left the rail: the pins screen is reached from "Host keys" on the Keys
    /// screen, which was always the other way in. Exact rather than a bound, so putting a row back is a
    /// decision somebody makes here rather than something that slips in.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheNavRailHoldsItsSwitcherFiveDestinationsAndTheUserChipAtTheWindowsMinimum()
    {
        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var rail = new NavRail { DataContext = shell };
                var window = LayoutHarness.HostAtMinimumSize(
                    rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);

                try
                {
                    var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();

                    var segments = buttons.Where(button => button.Classes.Contains("navseg")).ToList();
                    var rows = buttons.Where(button => button.Classes.Contains("nav")
                        && !button.Classes.Contains("navseg")).ToList();
                    var chip = buttons.Single(button => button.Classes.Contains("navuser"));

                    segments.Count.ShouldBe(3, "SSH, SFTP and S3");
                    rows.Count.ShouldBe(
                        5, "the mode-dependent first row, then Hosts, Keys, Snips and Logs");

                    foreach (var segment in segments)
                    {
                        segment.Bounds.Height.ShouldBeGreaterThan(20);
                    }

                    foreach (var row in rows.Append(chip))
                    {
                        row.Bounds.Height.ShouldBeGreaterThan(20);

                        // 255 wide, less the 1-pixel border down the rail's right edge, less the 14 of
                        // inset v5b's own padding gives each side. Stated exactly rather than as a lower
                        // bound: a row that stopped filling the width would leave a dead strip beside a
                        // destination, which is precisely the kind of near-miss a bound hides.
                        row.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 28);
                    }

                    LayoutHarness.Unreachable(window).ShouldBeEmpty();
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    // ---- The unlock screen ----

    /// <remarks>
    /// <para>
    /// The card a locked application is entirely made of, in its two shapes: an ordinary launch, and one
    /// where shells were left running and the disclosure about them appears. It was extracted from
    /// <c>MainWindow.axaml</c> to be measurable at all — that window cannot be shown here, so anything
    /// inside it is unmeasured by construction — and it is the card with the least room to spare.
    /// </para>
    /// <para>
    /// The status line is set to something long on purpose. It is bound to whatever the last thing that
    /// happened said, and the longest of those is a sentence about an expired sign-in, which is exactly the
    /// message this screen is most likely to be carrying on the launch where the extra rows also appear.
    /// </para>
    /// </remarks>
    [Theory]
    [InlineData(0)]
    [InlineData(2)]
    public async Task TheUnlockCardFitsTheCardItIsShownIn(int liveSessions)
    {
        shell.LiveSessionCount = liveSessions;
        shell.CanUnlockWithDevice = true;
        shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
            + "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";

        await MeasureCardAsync(static () => new UnlockCard());
    }

    [Fact]
    public async Task TheUnlockBoxTakesEnterAsUnlock()
    {
        // Enter is how everybody finishes typing a password, and this screen had no answer to it until the
        // gesture below existed: the passphrase box is where locking puts the keyboard, so the one thing a
        // user does without thinking did nothing at all until they found the button.
        //
        // The gesture is what can be asserted; that pressing it unlocks is ShellFlowTests' business,
        // against the command this binds to.
        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var card = new UnlockCard { DataContext = shell };
                var window = LayoutHarness.HostAtMinimumSize(
                    card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);

                try
                {
                    var binding = card.PassphraseBox.KeyBindings.ShouldHaveSingleItem();

                    binding.Gesture.ShouldBe(new KeyGesture(Key.Enter));
                    binding.Command.ShouldBeSameAs(shell.UnlockCommand);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    // ---- The sign-out confirmation ----

    /// <remarks>
    /// <para>
    /// The one new card that has to share a screen with an unlock prompt, and the only one whose height
    /// depends on what it is saying: the warning is a sentence about the outbox, and the disclosure about
    /// shells left running appears only when there are some. Both are wrapped paragraphs, which is the
    /// shape that grows.
    /// </para>
    /// <para>
    /// Measured in the space a card gives its contents rather than inside <c>MainWindow</c>, which cannot
    /// be laid out here — see <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>. What that leaves
    /// unchecked is the card's own frame, which is a fixed border and a constant padding.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheSignOutCardFitsTheCardItIsShownIn()
    {
        // Its tallest shape: a shell left running adds a disclosure box that an ordinary sign-out does not
        // have, an open transfer session adds a line beneath it, and a locked vault carries the longer of
        // the two warnings.
        shell.LiveSessionCount = 1;
        shell.Transfers.IsConnected = true;

        await MeasureCardAsync(static () => new SignOutCard());
    }

    /// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
    /// <remarks>
    /// The card is <em>built</em> inside the dispatched call rather than passed in already constructed, and
    /// that is not style. Avalonia binds <c>Dispatcher.UIThread</c> to whichever thread first asks for it, so
    /// a control constructed on the test thread before any other test has dispatched makes that thread the
    /// UI thread — and every later property set from the harness's own thread then throws. It depends on the
    /// order the tests happen to run in, which is why it survived until a phase that added new ones.
    /// </remarks>
    private Task MeasureCardAsync(Func<Control> build) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var card = build();
                card.DataContext = shell;

                var window = LayoutHarness.HostAtMinimumSize(
                    card, LayoutHarness.CardContentWidth, LayoutHarness.CardContentHeight);

                try
                {
                    LayoutHarness.Unreachable(window).ShouldBeEmpty();
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    // ---- Helpers ----

    /// <summary>Lays the drawer out at the width it declares for itself.</summary>
    private Task MeasureDrawerAsync(Action<IReadOnlyList<string>> assert) =>
        OnTheDrawerAsync((_, window) => assert(LayoutHarness.Unreachable(window)));

    private Task OnTheDrawerAsync(Action<HostDrawer, Window> body) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var drawer = new HostDrawer { DataContext = vault };

                var window = LayoutHarness.HostAtMinimumSize(
                    drawer, LayoutHarness.HostDrawerWidth, LayoutHarness.ScreenHeight);

                try
                {
                    body(drawer, window);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
    /// <remarks>
    /// The vault is the data context and the shell is not, which it used to be. The screen handed the vault
    /// to the sidebar from inside its own markup and needed the shell to do it; the drawer is a plain child
    /// and inherits what the screen has, so the indirection went away with the sidebar.
    /// </remarks>
    private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
        OnTheHostsScreenAsync((_, window) => assert(LayoutHarness.Unreachable(window)));

    private Task OnTheHostsScreenAsync(Action<HostsScreen, Window> body) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new HostsScreen { DataContext = vault };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    body(screen, window);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
    /// <remarks>
    /// <para>
    /// ◆ WAVE C's OWN FIX. This used to measure a full screen's width and height, which is roomier than what
    /// v5b's own session shell actually leaves the card once its padding, tab row, header and sidebar are
    /// taken out; the gap is closed here rather than merely admitted. <c>ConnectingCard</c> sits in the same
    /// <c>Grid.Column="0"</c> pane <c>ConnectingPane</c> occupies in <c>MainWindow.axaml</c>'s terminal usage
    /// — the sidebar is <c>Grid.Column="1"</c>, a true sibling rather than an overlay on top of this one — so
    /// its real rectangle is <see cref="LayoutHarness.SessionScreenWidth"/>/<see cref="LayoutHarness.SessionScreenHeight"/>,
    /// not the plain screen budget.
    /// </para>
    /// <para>
    /// The sidebar is showing whenever this card can be, which is what makes the width unconditional rather
    /// than a parameter here: <c>ShowsQuickAccessSidebar</c> on the terminal surface is
    /// <c>SelectedTab is not null</c>, and a connecting card has nothing to show without a selected tab
    /// either — see the two callers below, both of which select one before calling this.
    /// </para>
    /// </remarks>
    private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                shell.State = ShellState.Unlocked;
                shell.Tabs.Clear();
                shell.Tabs.Add(tab);
                shell.SelectedTab = tab;

                var card = new ConnectingCard { DataContext = shell };

                var window = LayoutHarness.HostAtMinimumSize(
                    card, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
    /// <remarks>
    /// <para>
    /// ◆ WAVE C's OWN FINDING, rather than wave C's own fix: this one turned out not to need the narrower
    /// budget wave B's remark predicted. <c>HostKeyCard</c> is not nested inside either session shell at all
    /// — <c>MainWindow.axaml</c> draws it as a sibling of the whole unlocked <c>Grid</c> (nav rail and content
    /// both), the last child before the titlebar/status-bar row, so its own scrim genuinely spans the full
    /// content area rather than the narrower column either session shell leaves. "Both cover the rectangle
    /// the terminal would be in" — <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>'s own remark — describes
    /// the intent the two states share, not this control's actual bounds.
    /// </para>
    /// <para>
    /// <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> — the nav rail already
    /// taken out, the session shell's own padding and sidebar not — stays the right measurement rather than
    /// the wrong one wave B's remark called it: it is narrower than what this control truly gets (the nav
    /// rail's own 255 pixels back), so a card proven to fit here is proven to fit the real, wider overlay too.
    /// <c>Border.card</c>'s own 520-pixel <c>MaxWidth</c> means neither number was ever the risk; what this
    /// records is that no gap was left for a later wave to close.
    /// </para>
    /// </remarks>
    private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var card = new HostKeyCard { DataContext = vault };

                var window = LayoutHarness.HostAtMinimumSize(
                    card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
    private Task MeasurePinsAsync(
        Action<IReadOnlyList<string>> assert,
        KnownHostsViewModel? pins = null) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new KnownHostsScreen { DataContext = pins ?? new KnownHostsViewModel(vault) };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Lays the logs screen out at the size it gets beside the nav rail.</summary>
    private Task MeasureLogsAsync(
        Action<IReadOnlyList<string>> assert,
        LogSection section,
        LogsViewModel? logs = null) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var model = logs ?? NewLogsScreen();
                model.Section = section;

                var screen = new LogsScreen { DataContext = model };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    private LogsViewModel NewLogsScreen(params LiveConnection[] live) =>
        new(session, () => live);

    /// <summary>
    /// Writes one of each kind of entry and reads them back.
    /// </summary>
    /// <remarks>
    /// Through the repositories the recorders write to, rather than through the recorders themselves: those
    /// write on a background task on purpose, and a layout suite that waited on one would be measuring
    /// rectangles behind a race.
    /// </remarks>
    private async Task<LogsViewModel> SeedLogsAsync()
    {
        await session.ConnectionLog.CreateAsync(
            session.ActiveVaultId,
            new ConnectionLogSecret
            {
                HostLabel = "customer-a-production-database",
                Address = "deployment-account@db-01.customer-a.internal:22022",
                StartedAt = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
                Duration = TimeSpan.FromMinutes(74),
                Outcome = ConnectionOutcome.Refused,
                DeviceName = "jaap-jan-workstation",
            },
            Token);

        await session.ActivityLog.CreateAsync(
            session.ActiveVaultId,
            new ActivityLogSecret
            {
                ItemKind = "Host",
                ItemId = Guid.CreateVersion7(),
                ItemLabel = "customer-a-production-database",
                Operation = ActivityOperation.Updated,
                ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags",
                At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
                DeviceName = "jaap-jan-workstation",
            },
            Token);

        var logs = NewLogsScreen(new LiveConnection(
            "customer-a-production-database",
            "deployment-account@db-01.customer-a.internal:22022",
            new DateTimeOffset(2026, 7, 31, 8, 0, 0, TimeSpan.Zero),
            "jaap-jan-workstation"));

        await logs.ReloadAsync(Token);

        return logs;
    }

    /// <summary>Lays the snippets screen out at the size it gets beside the nav rail.</summary>
    /// <remarks>
    /// The insert function throws. Nothing measured here presses a button, and a substitute that returned a
    /// plausible answer would make it possible to write a layout test that quietly exercised the transport.
    /// </remarks>
    private Task MeasureSnippetsAsync(
        Action<IReadOnlyList<string>> assert,
        SnippetsViewModel? snippets = null) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new SnippetsScreen { DataContext = snippets ?? NewSnippetsScreen() };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
        new(
            vault,
            () => target ?? InsertTarget.None,
            static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));

    /// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
    /// <summary>Lays the SFTP usage of <c>TransfersScreen</c> out at the real budget wave B's session shell leaves.</summary>
    /// <remarks>
    /// <c>transfers.IsConnected</c> decides the width the same way <c>ShowsQuickAccessSidebar</c> does at
    /// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
    /// this measures the tighter, sidebar-narrowed shape without having to say so twice.
    /// </remarks>
    private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new TransfersScreen { DataContext = transfers };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen,
                    LayoutHarness.SessionScreenWidth(transfers.IsConnected),
                    LayoutHarness.SessionScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the budget it actually gets.</summary>
    /// <remarks>
    /// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height. v5c-3
    /// gives S3 the session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — see
    /// <c>MainWindow.axaml</c>'s own remark on why — so it is measured at
    /// <see cref="LayoutHarness.BucketsScreenWidth"/>/<see cref="LayoutHarness.BucketsScreenHeight"/>, which
    /// take that padding and border off the full-bleed budget every other page gets and stop there: no tab
    /// row, header, status bar or sidebar to subtract, unlike <see cref="LayoutHarness.SessionScreenWidth"/>.
    /// </remarks>
    private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new TransfersScreen { DataContext = transfers };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.BucketsScreenWidth, LayoutHarness.BucketsScreenHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>Puts one transfer on the queue in a given state, without moving a byte.</summary>
    private void Enqueue(
        TransferDirection direction,
        string name,
        long length,
        long transferred,
        TransferState state,
        double bytesPerSecond = 0,
        string? failure = null) =>
        transfers.Transfers.Add(new TransferRowViewModel(new TransferSnapshot(
            Guid.CreateVersion7(),
            direction,
            name,
            Path.Combine(Path.GetTempPath(), name),
            SftpPath.Combine("/srv/releases", name),
            length,
            transferred,
            state,
            bytesPerSecond,
            failure)));

    /// <remarks>
    /// v5c-2: the old VaultsScreen this suite used to measure here is gone — Vaults is a settings page now,
    /// and its own layout coverage (populated lists, the rename and new-vault forms, the hand-over
    /// confirmation, the members panel) lives in <c>SettingsPagesLayoutTests</c> beside every other settings
    /// page's, measured against the settings content budget rather than this suite's full-chrome one.
    /// </remarks>
    private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
        OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));

    private Task OnTheVaultAsync(Action<KeychainScreen, Window> body) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new KeychainScreen { DataContext = vault };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    body(screen, window);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <remarks>
    /// Enough rows in every list that none is empty, because an empty list is the easiest case and the one
    /// least worth certifying.
    /// </remarks>
    private async Task SeedAsync()
    {
        // Before the hosts, so the tag picker in the host editor has chips in it and the rows below have
        // chips on them. A vault with no tags collapses both, and a collapsed control is the shape this
        // suite is least interested in certifying.
        //
        // Ten of them, and the number is doing work: the picker is a chip per tag, wrapped, so this is what
        // drives the sidebar's editor onto the MaxHeight of the ScrollViewer it gained in the same change —
        // and that capped pane is the thing which has to fit the column. Three tags left it short of the cap,
        // measuring a shape no user with a real keychain ever sees.
        foreach (var label in new[]
        {
            "pci", "eu-west-1", "postgres-16", "on-call", "customer-a", "bastion",
            "kubernetes-worker", "legacy", "staging", "database-primary",
        })
        {
            vault.NewTagCommand.Execute(null);
            vault.TagEditorLabel = label;
            await vault.SaveTagCommand.ExecuteAsync(null);
        }

        for (var i = 0; i < 6; i++)
        {
            vault.NewHostCommand.Execute(null);
            vault.EditorLabel = $"host-{i}";
            vault.EditorHostname = $"host-{i}.internal";
            vault.EditorUsername = "deploy";

            // Every tag on the first host, so the widest realistic row — a chip row that wraps — is what
            // gets measured rather than a bare label.
            foreach (var choice in vault.EditorTagChoices.Where(_ => i == 0).ToList())
            {
                vault.ToggleEditorTagCommand.Execute(choice);
            }

            await vault.SaveHostCommand.ExecuteAsync(null);
        }

        for (var i = 0; i < 4; i++)
        {
            vault.NewKeyCommand.Execute(null);
            vault.KeyEditorLabel = $"key-{i}";
            vault.KeyEditorPrivateKey =
                $"-----BEGIN OPENSSH PRIVATE KEY-----\nMATERIAL-{i}\n-----END OPENSSH PRIVATE KEY-----\n";
            await vault.SaveKeyCommand.ExecuteAsync(null);
        }

        for (var i = 0; i < 3; i++)
        {
            vault.NewCredentialCommand.Execute(null);
            vault.CredentialEditorLabel = $"credential-{i}";
            vault.CredentialEditorPassword = $"password-{i}";
            vault.CredentialEditorUsername = $"account-{i}";
            await vault.SaveCredentialCommand.ExecuteAsync(null);
        }

        // Pins come from approving a fingerprint at connect time, not from an editor, so they are seeded
        // through the store the connect path writes to. Two for one endpoint, because a host offering keys
        // of two algorithms is ordinary and the duplicate is one of the things this list has to show.
        foreach (var (host, algorithm) in new[]
        {
            ("host-0.internal", "ssh-ed25519"),
            ("host-0.internal", "ecdsa-sha2-nistp256"),
            ("gone.internal", "ssh-ed25519"),
        })
        {
            await knownHosts.TrustAsync(
                new HostKeyPresentation(
                    host, 22, algorithm, $"SHA256:{algorithm}-fingerprint-0123456789abcdefghijklmnop"),
                Token);
        }

        // Back to where the vault screen opens, so every test starts from the state a user would see.
        vault.Section = VaultSection.All;

        await vault.LoadAsync(Token);
    }

    /// <summary>
    /// Adds snippets, including the two shapes that decide this screen's height.
    /// </summary>
    /// <remarks>
    /// Not part of <see cref="SeedAsync"/>, so the empty state stays measurable — and because most keychains
    /// have none, which is the shape somebody sees the first time they open the screen.
    /// </remarks>
    private async Task SeedSnippetsAsync()
    {
        await vault.SaveSnippetAsync(
            vault.TargetVaultId,
            null,
            new SnippetSecret
            {
                Label = "tail the application log",
                Command = "sudo journalctl -u dodossh-api -f --since '10 minutes ago'",
                Notes = "Ctrl+C to stop.",
            },
            Token);

        await vault.SaveSnippetAsync(
            vault.TargetVaultId,
            null,
            new SnippetSecret
            {
                Label = "restart the api",
                Command = "sudo systemctl daemon-reload\nsudo systemctl restart dodossh-api\nsystemctl status dodossh-api --no-pager",
                Notes = "Check the on-call rota before running this in production.",
                RunsOnInsert = true,
            },
            Token);

        vault.Snippets.Count.ShouldBe(2);
    }

    /// <summary>
    /// Adds groups and files the seeded hosts across them.
    /// </summary>
    /// <remarks>
    /// Not part of <see cref="SeedAsync"/>, on purpose. A vault with no groups is what a new one is and what
    /// most of them stay, and it is the shape in which the sidebar draws no headings at all — so it has to
    /// remain the one every other test here measures.
    /// </remarks>
    private async Task SeedGroupsAsync(int count)
    {
        for (var i = 0; i < count; i++)
        {
            vault.GroupEditorLabel = $"customer-{i}-production";
            await vault.SaveGroupCommand.ExecuteAsync(null);
        }

        vault.Groups.Count.ShouldBe(count);

        // Filed through the host editor, which is the only way a user can do it, so this also exercises the
        // picker the sidebar's headings are built out of.
        for (var i = 0; i < vault.Hosts.Count; i++)
        {
            vault.SelectedHost = vault.Hosts[i];
            vault.EditSelectedHostCommand.Execute(null);

            vault.EditorSelectedGroup = vault.EditorGroupChoices
                .First(choice => choice.EntityId == vault.Groups[i % count].EntityId);

            await vault.SaveHostCommand.ExecuteAsync(null);
        }
    }
}
