using System.Globalization;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
// FakeDeviceKeyStore is compiled into this assembly from a source link and keeps its original namespace;
// see the csproj for why it is shared rather than reimplemented.
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;

namespace DodoSSH.Client.App.Tests;

/// <summary>
/// The whole path a user walks: sign in, enroll, keep the recovery code, unlock, add a host, sync.
/// </summary>
/// <remarks>
/// Runs with no Avalonia, no browser and no identity provider, because the view models are plain
/// observable objects and sign-in is a delegate. What that buys is that the states most likely to be got
/// wrong — the one that must not be skipped, and the one that has to work offline — are checked by a test
/// rather than by remembering to click through them.
/// </remarks>
public sealed class ShellFlowTests : IAsyncLifetime
{
    private const string Passphrase = "a sufficiently long passphrase";

    /// <summary>Everything this shell has copied, newest last.</summary>
    private readonly List<string> clipboard = [];

    /// <remarks>
    /// Far below the shipped profile, for the same reason as everywhere else: these tests are about the
    /// state machine, not about how expensive the passphrase is to attack.
    /// </remarks>
    private static readonly Argon2Profile CheapProfile =
        Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);

    private readonly FakeVaultServer server = new();

    /// <remarks>
    /// The real factory would need a reachable sshd, which <c>DodoSSH.Client.Ssh.Tests</c> covers against
    /// a container. Nothing in this suite connected before, so substituting it costs no coverage and makes
    /// the connect path reachable.
    /// </remarks>
    private readonly FakeSshConnectionFactory ssh = new();

    private int signInAttempts;

    /// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
    /// <remarks>
    /// Counted rather than merely allowed, because the interesting assertions about resuming are about how
    /// often it happens: once per launch when it works, and never again once the provider has refused.
    /// </remarks>
    private int resumeAttempts;

    private string? resumedWith;

    /// <summary>When set, resuming throws — how a revoked or rotated-away token is exercised.</summary>
    private Exception? resumeFailure;

    private string directory = null!;
    private ClientPaths paths = null!;
    private ClientCacheFactory caches = null!;
    private TerminalWorkspace workspace = null!;
    private VaultKnownHostStore knownHosts = null!;

    /// <remarks>
    /// A fake rather than the real TPM-backed store, and not for speed: the real one prompts for a Windows
    /// consent dialog on every save and every load, so a suite using it would block forever waiting for
    /// somebody to enter a PIN. What the shell has to get right is which buttons appear and what happens when
    /// one is pressed, and that is exactly what a fake keystore can answer.
    /// </remarks>
    private FakeDeviceKeyStore deviceKeys = null!;
    private MainWindowViewModel shell = null!;

    /// <inheritdoc />
    public ValueTask InitializeAsync()
    {
        // A real directory and a real SQLite file, because the production path is what StartAsync runs and
        // an in-memory database would skip the migration that creates the file.
        directory = Path.Combine(Path.GetTempPath(), $"dodossh-shell-{Guid.CreateVersion7():N}");
        paths = new ClientPaths(directory);
        caches = ClientCacheFactory.ForFile(paths.CacheFile);

        // The real store, not a stand-in. It is the one the application composes, its lifecycle is this
        // shell's business — opened on unlock, closed on lock — and the trust it records goes into the vault
        // this suite already has, so substituting one would only stop the wiring being tested.
        knownHosts = new VaultKnownHostStore();
        deviceKeys = new FakeDeviceKeyStore();

        // In-memory assets rather than the application's Avalonia-resource provider, which reads the
        // resource system at construction and needs an initialised toolkit. This is what
        // ITerminalAssetProvider is for; nothing in this suite renders anything.
        //
        // The page carries the same two placeholders the real one does, because FakeRenderer attaches by
        // reading them back out of the served page rather than by being handed the token.
        //
        // The renderer timeout is cut right down for the same reason: the tests that want a renderer attach
        // one themselves, so a connect in a test that does not would wait the shipped fifteen seconds out
        // in full, and that is fifteen seconds of a suite sitting still. A second rather than milliseconds
        // because this bounds FakeRenderer's own wait too — an in-process loopback handshake that has
        // already returned, so the margin is enormous, but not one worth making a loaded machine race for.
        workspace = new TerminalWorkspace(
            new InMemoryTerminalAssetProvider(
                new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
                {
                    ["/terminal"] = new(
                        "text/html; charset=utf-8",
                        System.Text.Encoding.UTF8.GetBytes(
                            $"<!doctype html><div data-token=\"{TerminalDataPlane.TokenPlaceholder}\" "
                            + $"data-socket=\"{TerminalDataPlane.SocketUrlPlaceholder}\"></div>")),
                }),
            ssh,
            TimeProvider.System,
            new TerminalWorkspaceOptions { RendererTimeout = TimeSpan.FromSeconds(1) });

        // Started, as the application does immediately after composing it. Without the accept loop the
        // page is never served, so nothing could attach a renderer.
        workspace.Start();

        shell = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            knownHosts,
            deviceKeys,
            SignInAsync,
            TimeProvider.System,
            ssh,
            CheapProfile,
            ResumeAsync,

            // ◆ A clipboard, where this fixture used to pass none. Both heads wire one now — the phone's
            // was simply never passed, which made every copy on that head answer "this machine has no
            // clipboard" on a device that plainly has one. A fixture without one modelled the bug rather
            // than the product. The branch for a machine that really has none is still covered, by a shell
            // built without one where it is the thing under test.
            copyToClipboard: text =>
            {
                clipboard.Add(text);
                return Task.CompletedTask;
            },

            // Inline, because this suite has no window and therefore no dispatcher to drain — the same
            // answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
            // Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
            // it first. Running the action where it was raised takes the thread out of the question, and
            // every phase this suite reports is raised on the thread doing the asserting anyway.
            post: action => action());

        return ValueTask.CompletedTask;
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        await shell.DisposeAsync();
        await workspace.DisposeAsync();
        caches.Dispose();

        if (Directory.Exists(directory))
        {
            Directory.Delete(directory, recursive: true);
        }
    }

    [Fact]
    public void TheTerminalFontSize_StartsAtTheSizeTheRendererDrawsAt()
    {
        // The page creates panes at its own constant until it is told otherwise. A different number here
        // would show as the terminal resizing itself on every launch, in front of the user.
        shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
    }

    /// <remarks>
    /// The cap is what stops "larger" arriving at a terminal too narrow to hold a prompt — this resizes the
    /// grid rather than magnifying it, so every step up is columns taken away from the remote. Asserted
    /// through the command rather than the clamp so the disabled state is covered with it: a button that
    /// keeps accepting presses and does nothing reads as the application having stopped responding.
    /// </remarks>
    [Fact]
    public void EnlargingPastTheCap_StopsAndSaysSo()
    {
        for (var i = 0; i < 100; i++)
        {
            shell.EnlargeTerminalFontCommand.Execute(null);
        }

        shell.TerminalFontSize.ShouldBe(ClientSettings.MaximumTerminalFontSize);
        shell.CanEnlargeTerminalFont.ShouldBeFalse();
        shell.CanShrinkTerminalFont.ShouldBeTrue();
    }

    [Fact]
    public void ShrinkingPastTheFloor_StopsAndSaysSo()
    {
        for (var i = 0; i < 100; i++)
        {
            shell.ShrinkTerminalFontCommand.Execute(null);
        }

        shell.TerminalFontSize.ShouldBe(ClientSettings.MinimumTerminalFontSize);
        shell.CanShrinkTerminalFont.ShouldBeFalse();
    }

    [Fact]
    public void ResettingTheTerminalFont_GoesBackToTheDefault()
    {
        shell.EnlargeTerminalFontCommand.Execute(null);
        shell.EnlargeTerminalFontCommand.Execute(null);

        shell.ResetTerminalFontCommand.Execute(null);

        shell.TerminalFontSize.ShouldBe(ClientSettings.DefaultTerminalFontSize);
    }

    /// <remarks>
    /// The reason the setting is a file beside the cache rather than a row inside it: this has to be
    /// readable on a launch that never unlocks anything, which is every launch up to the passphrase. A
    /// second shell over the same profile directory is exactly that launch.
    /// </remarks>
    [Fact]
    public async Task ASizeChosenOnce_IsThereOnTheNextLaunch()
    {
        shell.EnlargeTerminalFontCommand.Execute(null);
        shell.EnlargeTerminalFontCommand.Execute(null);

        var chosen = shell.TerminalFontSize;
        chosen.ShouldBe(ClientSettings.DefaultTerminalFontSize + 2);

        var relaunched = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            knownHosts,
            deviceKeys,
            SignInAsync,
            TimeProvider.System,
            ssh,
            CheapProfile,
            ResumeAsync);

        await using (relaunched.ConfigureAwait(false))
        {
            relaunched.TerminalFontSize.ShouldBe(chosen);
        }
    }

    [Fact]
    public async Task AFreshMachine_AsksForAServer()
    {
        await shell.StartAsync(Token);

        shell.State.ShouldBe(ShellState.NeedsServer);
        shell.IsNeedingServer.ShouldBeTrue();
        shell.IsOnline.ShouldBeFalse();

        // The migration ran, so the file exists before anyone has signed in to anything.
        File.Exists(paths.CacheFile).ShouldBeTrue();
    }

    [Fact]
    public async Task SigningInToAnUnenrolledAccount_AsksForAPassphrase()
    {
        await shell.StartAsync(Token);
        await shell.SignInCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.NeedsEnrollment);
        shell.IsOnline.ShouldBeTrue();
        shell.AccountName.ShouldBe("Alice Example");
    }

    [Fact]
    public async Task AnUnreachableServer_ReportsAndStaysPut()
    {
        server.SignInFailure = new HttpRequestException("No such host is known.");

        await shell.StartAsync(Token);
        await shell.SignInCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.NeedsServer);
        shell.StatusMessage.ShouldContain("No such host");
        shell.IsBusy.ShouldBeFalse("a failed command must not leave the window disabled");
    }

    [Fact]
    public async Task AnInvalidServerUrl_IsRejectedWithoutTouchingTheNetwork()
    {
        await shell.StartAsync(Token);

        shell.ServerUrl = "not a url";
        await shell.SignInCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.NeedsServer);
        shell.IsOnline.ShouldBeFalse();
    }

    /// <remarks>
    /// Separate from the case above because it is not caught by the same check. <c>Uri.TryCreate</c>
    /// accepts this happily as an absolute URI whose <em>scheme</em> is "localhost" and whose host is
    /// empty, so without an explicit scheme check the mistake surfaces much later as something that reads
    /// like a network fault.
    /// </remarks>
    [Fact]
    public async Task AServerUrlWithNoScheme_SaysSoRatherThanFailingLater()
    {
        await shell.StartAsync(Token);

        shell.ServerUrl = "localhost:5233";
        await shell.SignInCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.NeedsServer);
        shell.IsOnline.ShouldBeFalse();
        shell.StatusMessage.ShouldContain("http://");
        signInAttempts.ShouldBe(0, "a malformed URL must not open a browser");
    }

    /// <remarks>
    /// <para>
    /// The shipped default is a value a user is invited to accept unread — it is what the button under it
    /// will actually contact — so it is worth one assertion. It has twice been an address nothing was
    /// listening on: <c>https://localhost:7217</c>, the API's second launch profile, and then the first
    /// profile's <c>http://localhost:5233</c> in a release build, which is a machine an installed
    /// application is not running.
    /// </para>
    /// <para>
    /// The address is written out rather than compared against the constant itself. Asserting a constant
    /// against itself would pass however it were edited, and the whole point of this test is that no
    /// build ships a developer's loopback address.
    /// </para>
    /// </remarks>
    [Fact]
    public void TheDefaultServerUrl_IsTheHostedDeployment_InEveryBuild()
    {
        shell.ServerUrl.ShouldBe("https://ssh.dodotech.cloud");
    }

    [Theory]
    [InlineData("short", "short")]
    [InlineData("a sufficiently long passphrase", "a different one")]
    public async Task AWeakOrMismatchedPassphrase_DoesNotEnroll(string entered, string confirmation)
    {
        await SignedInAsync();

        shell.Passphrase = entered;
        shell.ConfirmPassphrase = confirmation;

        await shell.EnrollCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.NeedsEnrollment);
        server.EnrollmentCount.ShouldBe(0);
    }

    [Fact]
    public async Task TheRecoveryCodeScreen_CannotBeSkipped()
    {
        // The only moment the code exists. Losing it along with the passphrase means the vault is
        // unrecoverable and there is no server-side reset, so this is the one screen that has to insist.
        await EnrolledAsync();

        shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
        shell.RecoveryCode.ShouldNotBeNullOrWhiteSpace();

        // Trying to continue without confirming gets nowhere.
        shell.ConfirmRecoveryCodeCommand.Execute(null);
        shell.State.ShouldBe(ShellState.ShowingRecoveryCode);
        shell.RecoveryCode.ShouldNotBeNull();

        shell.RecoveryCodeWrittenDown = true;
        shell.ConfirmRecoveryCodeCommand.Execute(null);

        shell.State.ShouldBe(ShellState.Locked);

        // And it is dropped from memory, not merely hidden. It was never persisted; keeping it in a view
        // model for the rest of the session would undo that.
        shell.RecoveryCode.ShouldBeNull();
    }

    [Fact]
    public async Task AWrongPassphrase_KeepsTheVaultLocked()
    {
        await ReadyToUnlockAsync();

        shell.Passphrase = "not the passphrase";
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Locked);
        shell.Vault.ShouldBeNull();
        shell.StatusMessage.ShouldContain("did not open");
    }

    [Fact]
    public async Task UnlockingOpensTheVault()
    {
        await UnlockedAsync();

        shell.State.ShouldBe(ShellState.Unlocked);
        shell.Vault.ShouldNotBeNull();
        shell.Vault.VaultName.ShouldBe("Personal");

        // Cleared once used, so it is not sitting in a bound property for the rest of the session.
        shell.Passphrase.ShouldBeEmpty();
    }

    [Fact]
    public async Task ARestartUnlocksWithNoNetworkAtAll()
    {
        // The property the whole storage layer exists for, from the shell's point of view. The second
        // shell is given a sign-in delegate that fails if called.
        await EnrolledAndConfirmedAsync();
        await shell.LockCommand.ExecuteAsync(null);

        var offline = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            new VaultKnownHostStore(),
            new UnavailableDeviceKeyStore(),
            (_, _) => throw new InvalidOperationException("The shell went to the network to unlock."),
            TimeProvider.System,
            ssh,
            CheapProfile);

        await using var _ = offline.ConfigureAwait(false);

        await offline.StartAsync(Token);

        offline.State.ShouldBe(ShellState.Locked);
        offline.AccountName.ShouldBe("Alice Example");
        offline.IsOnline.ShouldBeFalse();

        offline.Passphrase = Passphrase;
        await offline.UnlockCommand.ExecuteAsync(null);

        offline.State.ShouldBe(ShellState.Unlocked);
        offline.Vault.ShouldNotBeNull();
    }

    /// <remarks>
    /// This used to assert the opposite of its last two lines — that a save queued the change and pushed
    /// nothing until Sync was pressed. Saving now pushes, so the assertion had to move rather than be
    /// deleted: the local-first guarantee it was really protecting is that the list updates without a
    /// server, and that is still covered by the offline test below.
    /// </remarks>
    [Fact]
    public async Task AddingAHost_ShowsItImmediatelyAndPushesIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewHostCommand.Execute(null);
        vault.IsEditing.ShouldBeTrue();

        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";
        vault.EditorPort = 2222;

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.IsEditing.ShouldBeFalse();

        var row = vault.Hosts.ShouldHaveSingleItem();
        row.Label.ShouldBe("prod-db");
        row.Address.ShouldBe("deploy@db.internal:2222");

        row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");
        row.Badge.ShouldBeEmpty();

        vault.PendingChanges.ShouldBe(0);
        server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
    }

    [Fact]
    public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.Status = "Reading something the user cares about.";

        await vault.AutoSyncAsync(Token);

        vault.Status.ShouldBe(
            "Reading something the user cares about.",
            "a background pass with no changes must not repaint the status line");

        vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag");
    }

    /// <remarks>
    /// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a
    /// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending
    /// change left and the count was unchanged whether the guard existed or not. It passed with the guard
    /// deleted, which is the only reason it was noticed.
    /// </remarks>
    [Fact]
    public async Task AnAutomaticPass_YieldsWhileACommandIsRunning()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        server.SyncFailure = new HttpRequestException("The server is having a bad day.");
        await AddHostAsync(vault, "prod-db");
        server.SyncFailure = null;

        vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");

        var pushesBefore = server.PushCount;

        // Standing in for a command in flight. A pass that pushed here would be competing with whatever
        // the user is doing for the same session and the same cache.
        vault.IsBusy = true;

        await vault.AutoSyncAsync(Token);

        server.PushCount.ShouldBe(pushesBefore, "the pass should have been skipped, not queued");
    }

    /// <remarks>
    /// The behaviour a background loop lives or dies by. A pass runs every minute; one that reported a
    /// transient server error would replace whatever the user was reading, once a minute, indefinitely.
    /// </remarks>
    [Fact]
    public async Task AnAutomaticPassThatFails_LeavesTheStatusAlone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.Status = "Reading something the user cares about.";
        server.SyncFailure = new HttpRequestException("The server is having a bad day.");

        await vault.AutoSyncAsync(Token);

        vault.Status.ShouldBe("Reading something the user cares about.");
        vault.IsBusy.ShouldBeFalse();

        // And pressing Sync still reports the real reason, so the failure is quiet rather than hidden.
        await vault.SyncCommand.ExecuteAsync(null);
        vault.Status.ShouldContain("bad day");
    }

    /// <summary>
    /// The whole point of the push channel: a pass that did not wait for the minute.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The timing is what makes this an assertion rather than a hope. The background timer is a full
    /// minute and the wait below gives up in ten seconds, so a pull that arrives can only have been
    /// caused by the notice — there is no interval at which the timer could have produced it.
    /// </para>
    /// <para>
    /// The vault id in the notice is arbitrary, and deliberately so: a pass synchronises every vault
    /// this session can reach, so the loop reads the notice as "there is something to fetch" and never
    /// as "fetch this one". A test that seeded a real id would imply a targeting this does not do.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task APushedNotice_SynchronisesWithoutWaitingForTheTimer()
    {
        await UnlockedAsync();

        // The unlock starts the loop, whose first act is a pass; waited out so the count below is a
        // baseline rather than a race with it.
        await EventuallyAsync(
            () => server.PullCount > 0,
            "the pass on open should have run");

        var before = server.PullCount;

        server.Notices.Push(Guid.CreateVersion7());

        await EventuallyAsync(
            () => server.PullCount > before,
            "a notice should have woken the loop long before the one-minute timer");
    }

    /// <remarks>
    /// The half that is easy to get wrong. The loop selects between two waits, and both have to survive
    /// losing: <c>PeriodicTimer</c> throws if a second wait is started while one is outstanding, and an
    /// abandoned channel read stays registered and swallows the next notice written. Either defect
    /// leaves the first notice working and every one after it silently lost, which is why one notice is
    /// not enough to prove this.
    /// </remarks>
    [Fact]
    public async Task NoticesKeepWakingTheLoop_NotJustTheFirst()
    {
        await UnlockedAsync();

        await EventuallyAsync(() => server.PullCount > 0, "the pass on open should have run");

        for (var round = 1; round <= 3; round++)
        {
            var before = server.PullCount;

            server.Notices.Push(Guid.CreateVersion7());

            await EventuallyAsync(
                () => server.PullCount > before,
                $"notice {round} should have woken the loop as the first one did");
        }
    }

    /// <summary>Waits for something a background loop is expected to do, or fails saying what.</summary>
    /// <remarks>
    /// Polled rather than signalled because the thing under test is a loop nobody hands a completion
    /// source to. The bound is generous — this is not measuring latency, only proving that the timer
    /// cannot be what caused the result.
    /// </remarks>
    private static async Task EventuallyAsync(Func<bool> condition, string because)
    {
        var deadline = TimeProvider.System.GetUtcNow().AddSeconds(10);

        while (TimeProvider.System.GetUtcNow() < deadline)
        {
            if (condition())
            {
                return;
            }

            await Task.Delay(TimeSpan.FromMilliseconds(20), Token);
        }

        throw new ShouldAssertException(because);
    }

    /// <remarks>
    /// <para>
    /// The page's own <c>term.focus()</c> focuses the textarea inside the document, which does nothing
    /// while the window's keyboard focus is still on the Connect button — so the first keystrokes of a
    /// session went to the shell's UI rather than the remote shell, and the terminal had to be clicked
    /// first. The view hands the control focus when this fires; see <c>NativeKeyboardFocus</c> for why an
    /// ordinary <c>Focus()</c> call is enough in that direction and not in the other.
    /// </para>
    /// <para>
    /// What this covers is the plumbing that carries the fix: that the raise is on the success path and
    /// happens once per session, and that the shell forwards it. Deleting the raise outright is already a
    /// build error — the event would be unused, and warnings are errors — but moving it, which is the
    /// likelier mistake, is not. It does <em>not</em> cover the focus call itself: that needs a native
    /// window, and headless Avalonia has none, which is exactly why this class of defect has escaped
    /// tests here before. Measured separately in a harness; see docs/platform-flags.md.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ConnectingAsksTheViewToFocusTheTerminal()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        var requests = 0;
        shell.TerminalSessionOpened += (_, _) => requests++;

        await vault.ConnectCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("Connected", Case.Insensitive);
        requests.ShouldBe(1);

        // Again, on a second session. This is why it is an event and not a bound flag: a boolean that was
        // already true would not move focus to the terminal the user just opened.
        await vault.ConnectCommand.ExecuteAsync(null);

        requests.ShouldBe(2);
    }

    /// <remarks>
    /// Focus must not be taken on a failure. A host-key prompt needs the keyboard on the prompt's own
    /// buttons, and taking it into a terminal that has no session would strand the decision.
    /// </remarks>
    [Fact]
    public async Task AFailedConnect_DoesNotAskForTheTerminalToBeFocused()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));

        var requests = 0;
        shell.TerminalSessionOpened += (_, _) => requests++;

        await vault.ConnectCommand.ExecuteAsync(null);

        vault.HasPendingHostKey.ShouldBeTrue();
        requests.ShouldBe(0);
    }

    // ---- Which surface is showing ----
    //
    // The tab strip is visible from every screen, so a terminal and a page are two things the window can be
    // showing rather than one screen among five. These fix that state machine. None of them can see the
    // WebView itself — headless Avalonia has no native window — but every transition below is decided here,
    // in ordinary objects, which is why they are worth having.

    /// <remarks>
    /// The point of the whole rework, stated as one assertion: a terminal opened from somewhere other than
    /// the hosts screen shows, and the screen underneath it does not move. Moving it would make opening a
    /// terminal a way to lose your place in a transfer that is still running.
    /// </remarks>
    [Fact]
    public async Task OpeningATerminalFromAnotherScreen_ShowsItAndLeavesTheScreenWhereItWas()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        shell.ShowScreenCommand.Execute(ShellScreen.Transfers);

        await vault.ConnectCommand.ExecuteAsync(null);

        shell.Surface.ShouldBe(ShellSurface.Terminal);
        shell.IsTerminalShowing.ShouldBeTrue();
        shell.IsShowingPages.ShouldBeFalse();

        shell.Screen.ShouldBe(ShellScreen.Transfers, "the page underneath is what closing the tab returns to");
    }

    [Fact]
    public async Task ANavRailClick_HidesTheTerminalAndKeepsTheTab()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);

        shell.IsTerminalShowing.ShouldBeFalse();
        shell.IsShowingPages.ShouldBeTrue();
        shell.IsKeychainShowing.ShouldBeTrue();

        // The session is untouched. Navigating away from a terminal is not a way to end one; only closing
        // its tab is.
        var tab = shell.Tabs.ShouldHaveSingleItem();
        tab.IsLive.ShouldBeTrue();
        shell.SelectedTab.ShouldBe(tab);
    }

    [Fact]
    public async Task ClickingATab_BringsTheTerminalBackFromAPage()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
        shell.IsTerminalShowing.ShouldBeFalse();

        shell.SelectTabCommand.Execute(shell.Tabs[0]);

        shell.IsTerminalShowing.ShouldBeTrue();
        shell.Screen.ShouldBe(ShellScreen.Preferences);
    }

    /// <remarks>
    /// <para>
    /// The phone's bottom bar names the terminal beside the pages, so the surface needs a command of its
    /// own — the desktop only ever reaches it implicitly, by opening a session or clicking a tab.
    /// </para>
    /// <para>
    /// Tested here rather than in the Android head because it is shared state-machine behaviour, and
    /// because nothing in this repository can run a test on a phone.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ShowingTheTerminal_SwitchesSurfaceWithoutChangingTheScreen()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
        shell.IsTerminalShowing.ShouldBeFalse();

        shell.ShowTerminalCommand.Execute(null);

        shell.IsTerminalShowing.ShouldBeTrue();
        shell.IsShowingPages.ShouldBeFalse();

        // The page underneath is remembered, not reset. Going to the terminal and back is navigation, and
        // navigation that forgets where you were is how a four-button bar becomes annoying.
        shell.Screen.ShouldBe(ShellScreen.Keychain);
    }

    /// <remarks>
    /// The one connection this application makes to a machine that is not in the keychain. What is worth
    /// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
    /// the port all come out of the one box.
    /// </remarks>
    [Fact]
    public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ManualTarget = "  deploy@build.internal:2222  ";
        vault.ManualPassword = "hunter2";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        var request = ssh.Requests.ShouldHaveSingleItem();
        request.Host.ShouldBe("build.internal");
        request.Port.ShouldBe(2222);
        request.Username.ShouldBe("deploy");
        request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");

        shell.IsTerminalShowing.ShouldBeTrue();
        shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
    }

    [Fact]
    public async Task AManualTargetWithNoPort_TakesTwentyTwo()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ManualTarget = "root@box";
        vault.ManualPassword = "hunter2";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
    }

    /// <remarks>
    /// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
    /// is never a login on anything, so the guess would fail at the remote as "authentication failed"
    /// rather than here as a sentence about the box that was typed into.
    /// </remarks>
    [Theory]
    [InlineData("", "Type a machine")]
    [InlineData("build.internal", "Say who to log in as")]
    [InlineData("deploy@", "Say who to log in as")]
    [InlineData("@build.internal", "Say who to log in as")]
    [InlineData("deploy@build.internal:70000", "between 1 and 65535")]
    [InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
    [InlineData("deploy@[fe80::1]", "bracketed IPv6")]
    public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
        string typed,
        string because)
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ManualTarget = typed;
        vault.ManualPassword = "hunter2";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        vault.ManualStatus.ShouldContain(because);
        ssh.Requests.ShouldBeEmpty();
        shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
    }

    /// <remarks>
    /// A password is the only thing this path can authenticate with, so an empty one is refused here rather
    /// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
    /// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
    /// </remarks>
    [Fact]
    public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ManualTarget = "root@box";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        vault.ManualStatus.ShouldContain("password");
        ssh.Requests.ShouldBeEmpty();
    }

    /// <remarks>
    /// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
    /// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
    /// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
    /// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
    /// the assertion would still catch it.
    /// </remarks>
    [Fact]
    public async Task TrustingAHostKey_RetriesTheAttemptThatRaisedItRatherThanTheSelectedHost()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.SelectedHost.ShouldNotBeNull();

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));

        vault.ManualTarget = "deploy@build.internal:2222";
        vault.ManualPassword = "hunter2";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        vault.HasPendingHostKey.ShouldBeTrue();

        ssh.Failure = null;

        await vault.TrustHostKeyCommand.ExecuteAsync(null);

        ssh.Requests.Count.ShouldBe(2);
        ssh.Requests[1].Host.ShouldBe("build.internal", "the retry is the attempt that asked the question");
        ssh.Requests[1].Port.ShouldBe(2222);
        ssh.Requests[1].Username.ShouldBe("deploy");
    }

    /// <remarks>
    /// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
    /// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
    /// is where its key, its password box and its refusals already live.
    /// </remarks>
    [Fact]
    public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
    {
        var vault = await ReadyToConnectAsync();

        var host = vault.Hosts[0];
        vault.SelectedHost = null;
        shell.ShowScreenCommand.Execute(ShellScreen.Preferences);

        shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));

        shell.IsHostsShowing.ShouldBeTrue();
        vault.SelectedHost.ShouldBe(host);
        vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
    }

    /// <remarks>
    /// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
    /// so it goes straight back in — without the password, which was never stored and whose absence is the
    /// point of that path rather than a gap in it.
    /// </remarks>
    [Fact]
    public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
    {
        var vault = await ReadyToConnectAsync();

        // Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
        // screen the application happens to open on.
        shell.ShowTerminalCommand.Execute(null);

        shell.ConnectToRecentCommand.Execute(
            Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));

        vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
        vault.ManualPassword.ShouldBeEmpty();
        shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
    }

    /// <remarks>
    /// A host deleted since it was connected to. The machine is still there and the keychain no longer knows
    /// about it, so the address is the honest answer rather than a tap that does nothing.
    /// </remarks>
    [Fact]
    public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
    {
        var vault = await ReadyToConnectAsync();

        shell.ShowTerminalCommand.Execute(null);

        shell.ConnectToRecentCommand.Execute(
            Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));

        vault.ManualTarget.ShouldBe("root@prod-db:22");
        shell.IsTerminalSurface.ShouldBeTrue();
    }

    /// <summary>One row of the connection log, built by hand.</summary>
    /// <remarks>
    /// Built rather than connected-and-closed, because what these three tests are about is which of the two
    /// branches a row takes — and driving that through a real connection, a real close and the recorder's own
    /// queue would test the recorder instead, which <c>DodoSSH.Client.Session.Tests</c> already does.
    /// </remarks>
    private static ConnectionLogRowViewModel Recent(string label, string address, Guid? hostId) =>
        new(
            new VaultItem<ConnectionLogSecret>(
                Guid.CreateVersion7(),
                new ConnectionLogSecret
                {
                    HostLabel = label,
                    Address = address,
                    HostId = hostId,
                    StartedAt = DateTimeOffset.UnixEpoch,
                    DeviceName = "a phone",
                },
                Version: 1,
                HasUnsyncedChanges: false,
                IsBlocked: false,
                IsReadOnly: false),
            isLive: false);

    // ---- Last connected ----
    //
    // The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
    // is the log read, and the two triggers below — the hosts screen coming back on screen and a session
    // ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.

    /// <remarks>
    /// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
    /// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
    /// none of this needs a fake clock — see the remark on <c>VaultViewModel.DescribeElapsed</c> itself.
    /// </remarks>
    [Theory]
    [InlineData(0, "just now")]
    [InlineData(59, "just now")]
    [InlineData(60, "1 min ago")]
    [InlineData(150, "2 min ago")]
    [InlineData(3599, "59 min ago")]
    [InlineData(3600, "1 hr ago")]
    [InlineData(7200, "2 hr ago")]
    [InlineData(86399, "23 hr ago")]
    [InlineData(86400, "1 day ago")]
    [InlineData(172800, "2 days ago")]
    public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
        VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);

    /// <remarks>
    /// The hosts screen's own activation — one of the two moments <c>VaultViewModel.RefreshLastConnectedAsync</c>
    /// is read on. <c>ReadyToConnectAsync</c> already visited this screen once, before the log held anything
    /// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
    /// transition <c>MainWindowViewModel.UpdateLastConnectedVisibility</c> actually keys off, rather than the
    /// level, which fired already.
    /// </remarks>
    [Fact]
    public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
    {
        var vault = await ReadyToConnectAsync();
        var host = vault.Hosts[0];

        await vault.Session.ConnectionLog.CreateAsync(
            vault.Session.ActiveVaultId,
            new ConnectionLogSecret
            {
                HostLabel = host.Label,
                Address = host.Address,
                HostId = host.EntityId,
                StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
                DeviceName = "a workstation",
            },
            Token);

        shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
        shell.ShowScreenCommand.Execute(ShellScreen.Hosts);

        await EventuallyAsync(
            () => host.LastConnectedText.Length > 0,
            "activating the hosts screen should have read the log");

        host.LastConnectedText.ShouldBe("3 days ago");
    }

    /// <remarks>
    /// A host the log has never named. Empty rather than a dash or the word "never" — see
    /// <c>HostRowViewModel.LastConnectedText</c>'s own remarks: a host nobody has connected to and a host
    /// whose log simply has not been read yet look identical from this row, and neither is a claim it can
    /// make on its own.
    /// </remarks>
    [Fact]
    public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
    {
        var vault = await ReadyToConnectAsync();
        var host = vault.Hosts[0];

        await vault.RefreshLastConnectedAsync(Token);

        host.LastConnectedText.ShouldBeEmpty();
    }

    /// <remarks>
    /// Decision 4's second half: a host with a session open right now shows the green dot instead of an
    /// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
    /// and the older answer is the one likeliest to be misread as current.
    /// </remarks>
    [Fact]
    public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
    {
        var vault = await ReadyToConnectAsync();
        var host = vault.Hosts[0];

        await vault.Session.ConnectionLog.CreateAsync(
            vault.Session.ActiveVaultId,
            new ConnectionLogSecret
            {
                HostLabel = host.Label,
                Address = host.Address,
                HostId = host.EntityId,
                StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
                DeviceName = "a workstation",
            },
            Token);

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        host.IsConnected.ShouldBeTrue();

        await vault.RefreshLastConnectedAsync(Token);

        host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
    }

    // The other trigger — a session ending on its own — has no test here. It runs inside
    // MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
    // RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
    // window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
    // queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
    // nothing about the one line this wave added, since the untestable half is wiring this wave did not
    // write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
    // directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
    // VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.

    /// <remarks>
    /// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
    /// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
    /// was raised from is part of it — see <c>MainWindowViewModel.IsTerminalShowing</c>.
    /// </remarks>
    [Fact]
    public async Task TheConnectSheet_HidesTheRendererAndLeavesTheSurfaceUnderIt()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.OpenConnectSheetCommand.Execute(null);

        shell.IsConnectSheetOpen.ShouldBeTrue();
        shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
        shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");

        shell.CloseConnectSheetCommand.Execute(null);

        shell.IsTerminalShowing.ShouldBeTrue();
    }

    /// <remarks>
    /// The flag holds the renderer blank, so one set while a page was showing would be a sheet nobody can
    /// see keeping a terminal hidden that nothing would put back.
    /// </remarks>
    [Fact]
    public async Task TheConnectSheet_RefusesToOpenOverAPage()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);

        shell.OpenConnectSheetCommand.Execute(null);

        shell.IsConnectSheetOpen.ShouldBeFalse();
    }

    /// <remarks>
    /// Every entry on the menu navigates, and none of them closes the sheet itself: leaving the terminal
    /// surface is what lowers it. That is the guarantee worth a test — it is what makes routes nobody wrote
    /// the sheet for, like closing the last tab or locking, safe.
    /// </remarks>
    [Fact]
    public async Task LeavingTheTerminal_LowersTheConnectSheetHoweverItIsLeft()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        // The menu's own second entry: one screen, over one view model, with the kind of remote chosen by
        // the thing that navigates.
        shell.OpenConnectSheetCommand.Execute(null);
        shell.ShowFilesCommand.Execute(RemoteKind.Bucket);

        shell.IsConnectSheetOpen.ShouldBeFalse();
        shell.IsBucketsShowing.ShouldBeTrue();

        // And a route the sheet was never wired to: back to the terminal, open it, then end the only shell
        // there is.
        shell.ShowTerminalCommand.Execute(null);
        shell.OpenConnectSheetCommand.Execute(null);
        shell.IsConnectSheetOpen.ShouldBeTrue();

        await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);

        shell.IsConnectSheetOpen.ShouldBeFalse("closing the last tab returns the surface to a page");
        shell.IsShowingPages.ShouldBeTrue();
    }

    /// <remarks>
    /// A visible WebView with no pane in it reads as the application having broken, so this is the one
    /// transition that moves the surface back on its own.
    /// </remarks>
    [Fact]
    public async Task ClosingTheLastTab_ReturnsToThePageThatWasShowing()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
        await vault.ConnectCommand.ExecuteAsync(null);

        await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);

        shell.Tabs.ShouldBeEmpty();
        shell.SelectedTab.ShouldBeNull();
        shell.IsTerminalShowing.ShouldBeFalse();
        shell.IsTransfersShowing.ShouldBeTrue("the page that was showing when the terminal opened");
    }

    [Fact]
    public async Task ClosingOneOfTwoTabs_KeepsTheTerminalShowing()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.Tabs.Count.ShouldBe(2);

        // The selected one, which is the second. The neighbour takes its place and the terminal stays.
        await shell.CloseTabCommand.ExecuteAsync(shell.SelectedTab!);

        shell.SelectedTab.ShouldBe(shell.Tabs.ShouldHaveSingleItem());
        shell.IsTerminalShowing.ShouldBeTrue();
    }

    [Fact]
    public async Task ClosingATabThatIsNotSelected_ChangesNothingAboutTheSurface()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);
        var first = shell.Tabs[0];

        await vault.ConnectCommand.ExecuteAsync(null);
        var second = shell.Tabs[1];

        await shell.CloseTabCommand.ExecuteAsync(first);

        shell.SelectedTab.ShouldBe(second);
        shell.IsTerminalShowing.ShouldBeTrue();
    }

    // ---- Connecting, while it is still happening ----
    //
    // A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
    // in which a tab exists and its session does not. Everything below is about that stretch: what the strip
    // shows, what the window draws in the terminal's rectangle, and what happens to the tab when the
    // connection answers — or does not.

    /// <remarks>
    /// The point of the whole thing, stated as one assertion: the tab is in the strip before the connection
    /// has answered, and the vault is not busy while it waits. A user who asked for a machine that is asleep
    /// used to get a status line and a window that did nothing for as long as the timeout took.
    /// </remarks>
    [Fact]
    public async Task Connecting_ShowsATabImmediatelyAndLeavesTheVaultUsable()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Gate = new TaskCompletionSource();
        var connecting = vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();
        tab.IsConnecting.ShouldBeTrue();
        tab.HasSession.ShouldBeFalse();
        tab.Label.ShouldBe("prod-db");
        tab.Address.ShouldBe("deploy@db.internal:22", "named for what is being dialled, not for what answered");

        // The card, not the renderer. They share one rectangle and there is no pane to put in it yet.
        shell.SelectedTab.ShouldBe(tab);
        shell.IsConnectingShowing.ShouldBeTrue();
        shell.IsTerminalShowing.ShouldBeFalse();

        // The gate this command does not hold. Everything else on this screen still works, which is the
        // difference between waiting and being stuck.
        vault.IsBusy.ShouldBeFalse();
        vault.Hosts.ShouldNotBeEmpty();

        ssh.Gate.SetResult();
        await connecting;

        tab.HasSession.ShouldBeTrue();
        tab.IsLive.ShouldBeTrue();
        tab.Status.ShouldBeEmpty("the pane speaks for itself from here on");

        shell.IsTerminalShowing.ShouldBeTrue();
        shell.IsConnectingShowing.ShouldBeFalse();
    }

    /// <remarks>
    /// <para>
    /// What the card draws while the stretch above is going on. The tab used to carry one line of prose
    /// fixed at the moment it was created, which made a handshake stuck on a key exchange look exactly like
    /// one stuck on a dead socket — and made a connection that was progressing look exactly like one that
    /// was not.
    /// </para>
    /// <para>
    /// The gate is held open on the step the fake reports before it, so this asserts the state the card is
    /// actually drawn in rather than one it passes through: one step behind, one step lit, three not
    /// reached. Nothing here waits or polls, which is the other half of the claim — the report arrives on
    /// the thread that raised it and the tab is up to date in the same turn.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task Connecting_LightsTheStepTheHandshakeHasActuallyReached()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Gate = new TaskCompletionSource();
        var connecting = vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();

        tab.Steps.Select(step => step.State).ShouldBe(
        [
            ConnectionStepState.Done,
            ConnectionStepState.Running,
            ConnectionStepState.Pending,
            ConnectionStepState.Pending,
            ConnectionStepState.Pending,
        ],
        "the renderer attached, the host is being reached, and nothing after that has happened");

        tab.StepsDone.ShouldBe(1, "the track fills to what finished, and the running step is not half a step");
        tab.Status.ShouldBe("Reaching the host");

        ssh.Gate.SetResult();
        await connecting;

        tab.Steps.ShouldAllBe(step => step.IsDone, "a session that opened got through all of them");
        tab.StepsDone.ShouldBe(tab.StepCount);
    }

    /// <remarks>
    /// The half of the step list a progress bar could not do: where it stopped is kept, and the steps behind
    /// it stay done. That is the difference between "that host is not there" and "that host is there and
    /// would not have me", and it is the question the reason sentence alone often does not settle.
    /// </remarks>
    [Fact]
    public async Task ARefusedConnection_KeepsTheStepItStoppedOn()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new InvalidOperationException("No route to host.");

        await vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();

        tab.Steps.Select(step => step.State).ShouldBe(
        [
            ConnectionStepState.Done,
            ConnectionStepState.Stopped,
            ConnectionStepState.Pending,
            ConnectionStepState.Pending,
            ConnectionStepState.Pending,
        ],
        "it got as far as reaching the host and no further");

        tab.Steps[1].Mark.ShouldBe("✕", "and says so without relying on the colour");

        // The reason still goes where it always went. The list says how far, and this says what happened.
        tab.Status.ShouldBe("No route to host.");
    }

    /// <remarks>
    /// A report that arrives for an attempt the shell has forgotten. Giving up on a connecting tab removes
    /// it while the handshake is still running — see <c>CloseTabAsync</c> — so every phase reported after
    /// that has no tab to land on. Dropped rather than resurrecting the tab, and above all not thrown: the
    /// handshake is still going, and its session is still adopted if it opens.
    /// </remarks>
    [Fact]
    public async Task GivingUpOnATab_LeavesLaterPhasesWithNothingToDo()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Gate = new TaskCompletionSource();
        var connecting = vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();
        await shell.CloseTabCommand.ExecuteAsync(tab);

        // Everything after the gate — the host key, the credential, the shell — is reported to a shell that
        // no longer has a tab for this attempt.
        ssh.Gate.SetResult();
        await connecting;

        // The session opened anyway and was adopted, which is the behaviour giving up already promised.
        var adopted = shell.Tabs.ShouldHaveSingleItem();
        adopted.HasSession.ShouldBeTrue();
        adopted.ShouldNotBe(tab);

        // And the forgotten tab was left where it was rather than being advanced from the sidelines.
        tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
    }

    /// <remarks>
    /// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
    /// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
    /// and it stays until it is closed.
    /// </remarks>
    [Fact]
    public async Task ARefusedConnection_LeavesATabCarryingTheReason()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new InvalidOperationException("No route to host.");

        await vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();
        tab.IsFailed.ShouldBeTrue();
        tab.IsLive.ShouldBeFalse();
        tab.Status.ShouldBe("No route to host.");

        shell.IsConnectingShowing.ShouldBeTrue("the card is where the reason is drawn");
        shell.IsTerminalShowing.ShouldBeFalse();

        // Closed like any other tab, and without asking the workspace to end a session that never existed.
        await shell.CloseTabCommand.ExecuteAsync(tab);

        shell.Tabs.ShouldBeEmpty();
        shell.IsHostsShowing.ShouldBeTrue();
    }

    /// <remarks>
    /// The other kind of not-connecting. An unknown host key is a question rather than a failure, so the tab
    /// goes — a tab saying the connection failed would be competing with the decision that is about to resume
    /// it — and the window stays where it was, because the decision is drawn over whatever that is.
    /// </remarks>
    [Fact]
    public async Task AnUnknownHostKey_TakesTheTabAwayAndShowsTheQuestion()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));

        shell.ShowScreenCommand.Execute(ShellScreen.Transfers);

        await vault.ConnectCommand.ExecuteAsync(null);

        shell.Tabs.ShouldBeEmpty();
        vault.HasPendingHostKey.ShouldBeTrue();

        // It used to assert IsHostsShowing here, because the prompt was a banner on that screen and this
        // handler navigated to it. The screen is untouched now and the card is over it instead.
        shell.Screen.ShouldBe(ShellScreen.Transfers);
        shell.IsHostKeyDecisionShowing.ShouldBeTrue("and it has to be reachable from wherever the user is");

        // And answering it connects, which is the whole reason the tab was not left saying it had failed.
        ssh.Failure = null;
        await vault.TrustHostKeyCommand.ExecuteAsync(null);

        shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue();
        shell.IsHostKeyDecisionShowing.ShouldBeFalse("the decision goes with the answer");
    }

    /// <remarks>
    /// Refusing, and the state it leaves behind. Both halves have to clear: the changed-key refusal is drawn
    /// over the surface on both heads and the phone's is an opaque full-screen panel, so a dismissal that left
    /// the flag set would leave that panel up over every screen the user went to next — including the host
    /// editor it tells them to open. See VaultViewModel.RejectHostKey, which used to clear only the other one.
    /// </remarks>
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task RefusingAHostKeyDecision_TakesItOffTheScreen(bool firstContact)
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = firstContact
            ? new SshHostKeyUnknownException(
                new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"))
            : new SshHostKeyMismatchException(
                new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
                "SHA256:the-pinned-key");

        await vault.ConnectCommand.ExecuteAsync(null);

        shell.IsHostKeyDecisionShowing.ShouldBeTrue(shell.StatusMessage);

        vault.RejectHostKeyCommand.Execute(null);

        vault.HasPendingHostKey.ShouldBeFalse();
        vault.HasHostKeyMismatch.ShouldBeFalse();
        shell.IsHostKeyDecisionShowing.ShouldBeFalse();

        // Nothing was pinned either way, so the machine is still a first contact next time.
        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
    }

    /// <remarks>
    /// <para>
    /// The one thing about the card that nothing else here can see, and the whole arrangement rests on it: the
    /// question belongs to the vault and the flag that draws the card — and collapses the terminal under it —
    /// belongs to the shell, so the shell has to re-raise it. Without that the card would never appear and,
    /// worse, never go away.
    /// </para>
    /// <para>
    /// Nothing else in this suite watches <c>PropertyChanged</c>, and this is why it is worth being the first:
    /// every other assertion about the flag reads it directly, and a direct read passes with the subscription
    /// deleted. The departure is the half that matters — <c>OnVaultConnectionFailed</c> raises the state
    /// itself as the tab goes, so an arrival is announced twice over, and answering the question is announced
    /// only from the vault's own notification.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostKeyDecision_IsAnnouncedToTheWindowWhenItArrivesAndWhenItGoes()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        var announcements = 0;
        shell.PropertyChanged += OnShellPropertyChanged;

        try
        {
            ssh.Failure = new SshHostKeyUnknownException(
                new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));

            await vault.ConnectCommand.ExecuteAsync(null);

            announcements.ShouldBeGreaterThan(0, "the card has to be told to appear");

            announcements = 0;
            vault.RejectHostKeyCommand.Execute(null);

            announcements.ShouldBeGreaterThan(0, "and to go, which is the half a stale flag would trap");
        }
        finally
        {
            shell.PropertyChanged -= OnShellPropertyChanged;
        }

        void OnShellPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            if (string.Equals(
                e.PropertyName,
                nameof(MainWindowViewModel.IsHostKeyDecisionShowing),
                StringComparison.Ordinal))
            {
                announcements++;
            }
        }
    }

    /// <remarks>
    /// Giving up on a connection that is still in flight. The tab goes at once — that is what the button
    /// promises — and the handshake that finishes afterwards is adopted rather than dropped, because a shell
    /// running with nothing in the window naming it is worse than a tab that comes back.
    /// </remarks>
    [Fact]
    public async Task ClosingATabThatIsStillConnecting_TakesItAwayAndKeepsWhateverArrives()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Gate = new TaskCompletionSource();
        var connecting = vault.ConnectCommand.ExecuteAsync(null);

        await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);

        shell.Tabs.ShouldBeEmpty();
        shell.IsHostsShowing.ShouldBeTrue();

        ssh.Gate.SetResult();
        await connecting;

        shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue("the session is real, so it gets a tab");
    }

    /// <remarks>
    /// Two at once, which is the other thing not holding the vault made possible — and the reason an attempt
    /// carries an id rather than being found by the host's name.
    /// </remarks>
    [Fact]
    public async Task TwoConnectionsCanBeInFlightAtOnce()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await AddHostAsync(vault, "stage-web");

        ssh.Gate = new TaskCompletionSource();

        vault.SelectedHost = Host(vault, "prod-db");
        var first = vault.ConnectCommand.ExecuteAsync(null);

        vault.SelectedHost = Host(vault, "stage-web");
        var second = vault.ConnectCommand.ExecuteAsync(null);

        shell.Tabs.Select(tab => tab.Label).ShouldBe(["prod-db", "stage-web"]);
        shell.Tabs.ShouldAllBe(tab => tab.IsConnecting);

        ssh.Gate.SetResult();
        await first;
        await second;

        shell.Tabs.ShouldAllBe(tab => tab.HasSession);
    }

    /// <remarks>
    /// Locking does not end a handshake any more than it ends a shell, and the tab standing in for one is
    /// shell state that survives a lock. So the answer still has to arrive somewhere: without it the tab
    /// would say "connecting…" for ever and the session it opened would have nothing naming it, and so no
    /// way to be closed.
    /// </remarks>
    [Fact]
    public async Task AConnectionInFlightWhenTheVaultLocks_StillLandsInItsTab()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Gate = new TaskCompletionSource();
        var connecting = vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();

        await shell.LockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Locked);
        shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab, "tabs outlive the vault that opened them");

        ssh.Gate.SetResult();
        await connecting;

        tab.HasSession.ShouldBeTrue();
        tab.IsLive.ShouldBeTrue();
    }

    /// <remarks>
    /// A tab is marked by whether its terminal is the thing on screen, not by whether it is the selected
    /// one — the selection survives navigating away, which is what makes the strip a way back rather than a
    /// way to lose a shell. Two "you are here" marks at once is one too many, and the rail's own entries
    /// already make the same distinction.
    /// </remarks>
    [Fact]
    public async Task ATabIsMarkedOnlyWhileItsTerminalIsShowing()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Gate = new TaskCompletionSource();
        var connecting = vault.ConnectCommand.ExecuteAsync(null);

        var tab = shell.Tabs.ShouldHaveSingleItem();
        tab.IsShowing.ShouldBeTrue("the connecting card is what the window is showing");

        // Navigating away during the connection, which is the case this most exists for: the connection goes
        // on, the tab stays selected, and nothing in the strip claims to be on screen.
        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);

        tab.IsShowing.ShouldBeFalse();
        tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");

        ssh.Gate.SetResult();
        await connecting;

        tab.IsShowing.ShouldBeFalse("a connection that finishes while you are elsewhere does not grab the window");

        shell.SelectTabCommand.Execute(tab);

        tab.IsShowing.ShouldBeTrue();

        // And the palette, which draws over the same rectangle.
        shell.ToggleSearchCommand.Execute(null);
        tab.IsShowing.ShouldBeFalse();
    }

    /// <remarks>
    /// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the
    /// terminal share a rectangle, and the card is the one that has to win.
    /// </remarks>
    [Fact]
    public async Task Locking_HidesTheTerminalWhateverTheSurfaceWas()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.IsTerminalShowing.ShouldBeTrue();

        await shell.LockCommand.ExecuteAsync(null);

        shell.IsTerminalShowing.ShouldBeFalse();
        shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue("locking does not end a session");
    }

    [Fact]
    public async Task AnUnlock_LandsOnAPageEvenWithATabStillOpen()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        await shell.LockCommand.ExecuteAsync(null);

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);

        shell.IsHostsShowing.ShouldBeTrue();
        shell.IsTerminalShowing.ShouldBeFalse();
    }

    [Fact]
    public async Task ThePalette_HidesTheTerminalAndClosingItBringsItBack()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.ToggleSearchCommand.Execute(null);

        shell.IsSearching.ShouldBeTrue();
        shell.IsTerminalShowing.ShouldBeFalse("the palette draws over the terminal's rectangle");

        shell.CloseSearchCommand.Execute(null);

        shell.IsTerminalShowing.ShouldBeTrue();
    }

    /// <remarks>
    /// The palette searches a host's vault name, and only where the row prints it — which a session holding
    /// one vault never does. Matching it anyway would answer "personal" with the entire keychain, ranked
    /// behind nothing and explained by nothing on screen. See <c>MainWindowViewModel.Rank</c>.
    /// </remarks>
    [Fact]
    public async Task ThePalette_DoesNotMatchTheVaultNameWhenThereIsOnlyOneVaultToBeIn()
    {
        var vault = await ReadyToConnectAsync();

        vault.VaultName.ShouldBe("Personal", "which is the word this test types");

        shell.ToggleSearchCommand.Execute(null);

        shell.SearchResults.ShouldHaveSingleItem()
            .HasVaultBadge.ShouldBeFalse("one vault is nothing to tell apart");

        shell.SearchText = "personal";

        shell.SearchResults.ShouldBeEmpty();
    }

    /// <remarks>
    /// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
    /// terminal fills the window would point at a screen that is not showing — and the selected tab already
    /// carries that mark, in the strip.
    /// </remarks>
    [Fact]
    public async Task TheNavRailLightsExactlyOneEntryOnAPage_AndNoneOnATerminal()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        LitEntries().ShouldBe(1);

        await vault.ConnectCommand.ExecuteAsync(null);
        LitEntries().ShouldBe(0);

        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
        LitEntries().ShouldBe(1);
        shell.IsKeychainShowing.ShouldBeTrue();

        int LitEntries() => new[]
        {
            shell.IsHostsShowing,
            shell.IsTransfersShowing,
            shell.IsKeychainShowing,
            shell.IsVaultsShowing,
            shell.IsPreferencesShowing,
        }.Count(lit => lit);
    }

    /// <remarks>
    /// <para>
    /// The palette can be opened from any screen, and this used to be why an unknown host key moved the
    /// window: the prompt was a banner on the hosts screen, so the shell navigated there before letting the
    /// vault raise it, or the connection would have blocked on a question behind whatever the user was
    /// looking at.
    /// </para>
    /// <para>
    /// The decision is drawn over the surface now, on both heads, so nothing moves — and this is the same
    /// test inverted, kept rather than deleted because the requirement it was written for still holds. What
    /// changed is how it is met: the question has to be answerable from where the user is, not the user
    /// taken to where the question is.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ConnectingFromThePalette_AsksAboutTheHostKeyWithoutMovingTheWindow()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        shell.ShowScreenCommand.Execute(ShellScreen.Transfers);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));

        shell.ToggleSearchCommand.Execute(null);
        shell.SelectedSearchResult = shell.SearchResults[0];

        await shell.ConnectToSearchResultCommand.ExecuteAsync(null);

        vault.HasPendingHostKey.ShouldBeTrue();

        shell.Screen.ShouldBe(
            ShellScreen.Transfers, "the transfer the user was looking at is still what is underneath");

        shell.IsHostKeyDecisionShowing.ShouldBeTrue("and the card is over it");

        // No assertion about the renderer here, deliberately: this attempt's tab was the only one, so it is
        // collapsed for want of a session whatever the occlusion rule says, and a test that cannot fail is
        // worse than no test. That claim belongs where a live terminal is behind the card — see
        // AChangedHostKey_CollapsesTheTerminalItIsRefusedOver.
    }

    /// <remarks>
    /// The connection with no host, which is the case the old navigation was worst for: it took the box that
    /// was typed into away and asked about the machine on a list the machine is deliberately not on. The
    /// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
    /// and what the user comes back to whichever way they answer.
    /// </remarks>
    [Fact]
    public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        shell.ShowTerminalCommand.Execute(null);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));

        vault.ManualTarget = "deploy@build.internal:2222";
        vault.ManualPassword = "hunter2";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        vault.HasPendingHostKey.ShouldBeTrue();

        shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
        shell.IsHostKeyDecisionShowing.ShouldBeTrue();
    }

    /// <remarks>
    /// A changed key is the other half of the same control and the reason it collapses the renderer: a second
    /// connection can be refused while a first one is open, and the refusal has to be readable over it.
    /// </remarks>
    [Fact]
    public async Task AChangedHostKey_CollapsesTheTerminalItIsRefusedOver()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        // One live session first, so there is something in the rectangle for the refusal to be drawn over.
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.IsTerminalShowing.ShouldBeTrue(shell.StatusMessage);

        ssh.Failure = new SshHostKeyMismatchException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
            "SHA256:the-pinned-key");

        await vault.ConnectCommand.ExecuteAsync(null);

        vault.HasHostKeyMismatch.ShouldBeTrue();
        shell.IsHostKeyDecisionShowing.ShouldBeTrue();
        shell.IsTerminalShowing.ShouldBeFalse("the card would otherwise be sliced at the WebView's edge");

        // And it comes back when the refusal is put away, rather than needing a tab click to restore it.
        vault.RejectHostKeyCommand.Execute(null);

        shell.IsHostKeyDecisionShowing.ShouldBeFalse();
        shell.IsTerminalShowing.ShouldBeTrue();
    }

    [Fact]
    public async Task TrustingAHostKey_PinsItInTheVaultAndConnects()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:first-contact"));

        await vault.ConnectCommand.ExecuteAsync(null);
        vault.HasPendingHostKey.ShouldBeTrue();

        // The second connection is the one that succeeds, which is what a trust-and-retry actually is: the
        // handshake is refused, the user decides, and a fresh connection is made with the pin in place.
        ssh.Failure = null;

        await vault.TrustHostKeyCommand.ExecuteAsync(null);

        vault.HasPendingHostKey.ShouldBeFalse();

        // Two connection attempts: the one that was refused and the one the pin allowed. Asserted on the
        // factory rather than on the status line, which the push that follows a trust legitimately repaints.
        ssh.Requests.Count.ShouldBe(2);

        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token))
            .ShouldBe("SHA256:first-contact");

        // Pushed as part of trusting, so the next machine to sync is not asked the same question.
        vault.PendingChanges.ShouldBe(0);
    }

    [Fact]
    public async Task APinnedHostKey_SurvivesLockingAndUnlocking()
    {
        // The gap this whole item closes, from the shell's point of view: the store is opened on unlock and
        // its contents come out of the vault, so approving a fingerprint is a decision that lasts.
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:approved"));

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Failure = null;
        await vault.TrustHostKeyCommand.ExecuteAsync(null);

        await shell.LockCommand.ExecuteAsync(null);

        // Locked means locked: the pins go with the vault keys, so nothing can answer a host key question
        // while the window is showing an unlock screen.
        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);

        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBe("SHA256:approved");
    }

    [Fact]
    public async Task ForgettingAHostKey_ClearsThePinAndTheRefusal()
    {
        // The way back from a rebuilt server, and the reason a mismatch can stay a hard refusal: the user
        // withdraws trust deliberately, from the host's own editor, rather than clicking past a warning.
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        ssh.Failure = new SshHostKeyUnknownException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-old-key"));

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Failure = null;
        await vault.TrustHostKeyCommand.ExecuteAsync(null);

        // The server is rebuilt and offers something else.
        ssh.Failure = new SshHostKeyMismatchException(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-new-key"),
            "SHA256:the-old-key");

        await vault.ConnectCommand.ExecuteAsync(null);
        vault.HasHostKeyMismatch.ShouldBeTrue();

        vault.EditSelectedHostCommand.Execute(null);
        vault.CanForgetHostKey.ShouldBeTrue();

        await vault.ForgetHostKeyCommand.ExecuteAsync(null);

        // The refusal that sent the user here is about a pin that no longer exists, so it goes too.
        vault.HasHostKeyMismatch.ShouldBeFalse();

        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();

        // And the withdrawal was pushed rather than left for the timer: the other machines are the ones
        // still refusing to connect to a server that has been rebuilt. The wording of the message is
        // asserted in ForgettingAHostKeyThatWasNeverPinned_SaysSo, where no pass overwrites the status.
        vault.PendingChanges.ShouldBe(0);
    }

    [Fact]
    public async Task ForgettingAHostKeyThatWasNeverPinned_SaysSo()
    {
        var vault = await ReadyToConnectAsync();

        vault.EditSelectedHostCommand.Execute(null);
        await vault.ForgetHostKeyCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("Nothing was pinned");
    }

    [Fact]
    public async Task ThereIsNothingToForgetOnAHostThatDoesNotExistYet()
    {
        // The button is hidden while a host is being created, because the pin belongs to an address that has
        // not been saved anywhere yet.
        var vault = await ReadyToConnectAsync();

        vault.NewHostCommand.Execute(null);
        vault.CanForgetHostKey.ShouldBeFalse();

        vault.CancelEditCommand.Execute(null);
        vault.CanForgetHostKey.ShouldBeFalse();

        vault.EditSelectedHostCommand.Execute(null);
        vault.CanForgetHostKey.ShouldBeTrue();
    }

    /// <remarks>
    /// The shell stops forwarding once the vault is gone. Dropping the detach half of that would compile
    /// and pass every other test, while leaving a discarded vault able to move focus in a locked window.
    /// </remarks>
    [Fact]
    public async Task LockingStopsTheShellForwardingFocusRequests()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        var requests = 0;
        shell.TerminalSessionOpened += (_, _) => requests++;

        await vault.ConnectCommand.ExecuteAsync(null);
        requests.ShouldBe(1);

        await shell.LockCommand.ExecuteAsync(null);

        shell.Vault.ShouldBeNull();

        // The discarded vault is detached, so even a late raise from it reaches nobody.
        await vault.ConnectCommand.ExecuteAsync(null);

        requests.ShouldBe(1);
    }

    [Fact]
    public async Task AnInvalidHost_IsRefusedWithAReason()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "   ";
        vault.EditorHostname = "db.internal";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.IsEditing.ShouldBeTrue("the editor should stay open so the user can fix it");
        vault.Hosts.ShouldBeEmpty();
        vault.Status.ShouldContain("needs a name");
    }

    /// <remarks>
    /// This is what earns the right to let a background pass fail silently. The automatic push after a save
    /// is best-effort; the outbox is the durable part. If a failed pass dropped the change, "quiet" would
    /// mean "lost".
    /// </remarks>
    [Fact]
    public async Task AQueueLeftByAFailedPass_IsStillSentByTheNextSync()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        server.SyncFailure = new HttpRequestException("The server is having a bad day.");

        await AddHostAsync(vault, "prod-db");

        server.LiveRowCount.ShouldBe(0, "the automatic push should have failed");
        vault.PendingChanges.ShouldBe(1, "and the change should still be queued");
        vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeTrue();

        server.SyncFailure = null;
        await vault.SyncCommand.ExecuteAsync(null);

        server.LiveRowCount.ShouldBe(1);
        vault.PendingChanges.ShouldBe(0);
        vault.Hosts.ShouldHaveSingleItem().HasUnsyncedChanges.ShouldBeFalse();
        vault.Status.ShouldContain("Synchronised");
    }

    [Fact]
    public async Task EditingAHostRoundTripsThroughTheEditor()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await vault.SyncCommand.ExecuteAsync(null);

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorLabel.ShouldBe("prod-db");
        vault.EditorHostname.ShouldBe("db.internal");

        vault.EditorNotes = "rotate quarterly";
        await vault.SaveHostCommand.ExecuteAsync(null);
        await vault.SyncCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.Notes.ShouldBe("rotate quarterly");
    }

    [Fact]
    public async Task DeletingAHost_RemovesItLocallyAndPushesTheTombstone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = vault.Hosts[0];
        await DeleteSelectedHostAsync(vault);

        vault.Hosts.ShouldBeEmpty();

        // Pushed without a second action. A tombstone that sat in the outbox would let the item come back
        // on a machine that synced in the meantime.
        server.LiveRowCount.ShouldBe(0);
        vault.PendingChanges.ShouldBe(0);
    }

    // ---- The question in front of a deletion ----

    /// <remarks>
    /// The half that makes the confirmation worth having: pressing DELETE has to change nothing at all. A
    /// card that appeared after the item had already gone would be a receipt, not a question.
    /// </remarks>
    [Fact]
    public async Task DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];

        vault.DeleteHostCommand.Execute(null);

        var question = vault.PendingDeletion.ShouldNotBeNull();
        question.Question.ShouldContain("prod-db", Case.Insensitive);
        vault.IsConfirmingDeletion.ShouldBeTrue();
        vault.ShowsHostActions.ShouldBeFalse("the buttons are what the question replaces");

        vault.Hosts.ShouldHaveSingleItem();
        server.LiveRowCount.ShouldBe(1);

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Hosts.ShouldBeEmpty();
        vault.PendingDeletion.ShouldBeNull("the question goes when it is answered");
        vault.ShowsHostActions.ShouldBeTrue();
    }

    [Fact]
    public async Task CancellingADeletion_LeavesTheItemWhereItWas()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "prod deploy");
        vault.SelectedCredential = vault.Credentials[0];

        vault.DeleteCredentialCommand.Execute(null);
        vault.CancelDeleteCommand.Execute(null);

        vault.PendingDeletion.ShouldBeNull();

        // And the answer that would have deleted it has nothing left to act on.
        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Credentials.ShouldHaveSingleItem();
        server.LiveRowCount.ShouldBe(1);
    }

    /// <remarks>
    /// What the question is for. A key that two hosts authenticate with is not the same deletion as one
    /// nothing uses, and the hosts do not fall back to a typed password when it goes — they refuse, which is
    /// asserted from the connect path's side in
    /// <see cref="AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword"/>.
    /// </remarks>
    [Fact]
    public async Task TheQuestionAboutAKey_CountsTheHostsThatAuthenticateWithIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        var keyId = vault.Keys[0].EntityId;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "prod-web");
        await BindKeyAsync(vault, Host(vault, "prod-db"), keyId);
        await BindKeyAsync(vault, Host(vault, "prod-web"), keyId);

        vault.SelectedKey = vault.Keys[0];
        vault.DeleteKeyCommand.Execute(null);

        var question = vault.PendingDeletion.ShouldNotBeNull();
        question.HasUsage.ShouldBeTrue();
        question.Usage.ShouldContain("2 hosts");
        question.Usage.ShouldContain("prod-db");
        question.Usage.ShouldContain("prod-web");

        // And the sentence above it says how far the deletion travels, which needs no host at all.
        question.Consequence.ShouldContain("no undo", Case.Insensitive);
    }

    /// <remarks>
    /// A key nothing uses gets no scare line, which is the other half of counting: a warning that appeared
    /// every time would say nothing the second time.
    /// </remarks>
    [Fact]
    public async Task TheQuestionAboutAKeyNothingUses_SaysNothingAboutHosts()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "spare");
        await AddHostAsync(vault, "prod-db");

        vault.SelectedKey = vault.Keys[0];
        vault.DeleteKeyCommand.Execute(null);

        vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeFalse();
    }

    /// <remarks>
    /// The failure this guards against is a question answered about something else: arm the deletion, click
    /// another row, press the button that is still on screen. The armed item is what the answer acts on, and
    /// choosing a different one takes the question away rather than re-aiming it.
    /// </remarks>
    [Fact]
    public async Task ChoosingSomethingElse_TakesTheQuestionAway()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "prod deploy");
        await AddCredentialAsync(vault, "staging deploy");
        await vault.LoadAsync(Token);

        vault.Section = VaultSection.Credentials;
        vault.SelectedVaultItem = vault.VaultItems[0];

        vault.DeleteCredentialCommand.Execute(null);
        vault.PendingDeletion.ShouldNotBeNull();

        vault.SelectedVaultItem = vault.VaultItems[1];

        vault.PendingDeletion.ShouldBeNull();

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Credentials.Count.ShouldBe(2, "nothing was agreed to");
    }

    /// <remarks>
    /// The case the naive rule got wrong. A reload replaces every row object in the list, so disarming on
    /// any change of the selected <em>row</em> would let the pass that runs every minute take the card away
    /// from somebody halfway through reading it. The entity id is what the rule compares.
    /// </remarks>
    [Fact]
    public async Task ASyncUnderneathAnArmedQuestion_LeavesItAlone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];

        vault.DeleteHostCommand.Execute(null);
        var armed = vault.PendingDeletion.ShouldNotBeNull();

        await vault.SyncCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);

        vault.PendingDeletion.ShouldBe(armed);

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Hosts.ShouldBeEmpty();
    }

    /// <remarks>
    /// Opening an editor is the other way the pane the question is in stops being about the question: the
    /// vault screen's Add buttons stay on screen beside the detail pane, so a password editor can open over
    /// an armed deletion. It disarms rather than stacking two forms in a 244-pixel column.
    /// </remarks>
    [Fact]
    public async Task OpeningAnEditor_TakesTheQuestionAway()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "prod deploy");
        vault.SelectedCredential = vault.Credentials[0];

        vault.DeleteCredentialCommand.Execute(null);
        vault.PendingDeletion.ShouldNotBeNull();

        vault.NewCredentialCommand.Execute(null);

        vault.IsEditingCredential.ShouldBeTrue();
        vault.PendingDeletion.ShouldBeNull();
    }

    /// <remarks>
    /// An answer to a question about something that has since gone — the realistic way being a pass that
    /// pulled somebody else's deletion. The reload that brings that news normally moves the selection and
    /// takes the question with it; this holds the guard behind that, which is what keeps a stale agreement
    /// from being a silent no-op under a card that has just been pressed.
    /// </remarks>
    [Fact]
    public async Task AnsweringAboutSomethingAlreadyGone_SaysSo()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        vault.SelectedKey = vault.Keys[0];

        vault.DeleteKeyCommand.Execute(null);
        vault.PendingDeletion.ShouldNotBeNull();

        // Underneath the question, as another machine's deletion would arrive.
        await vault.Session.SshKeys.DeleteAsync(
            vault.Session.ActiveVaultId, vault.Keys[0].EntityId, Token);
        await vault.LoadAsync(Token);

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("no longer here");
    }

    [Fact]
    public async Task SyncingWhileOffline_QueuesRatherThanFailing()
    {
        await EnrolledAndConfirmedAsync();
        await shell.LockCommand.ExecuteAsync(null);

        // Locking does not drop the connection, so take a fresh shell that never signed in.
        var offline = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            new VaultKnownHostStore(),
            new UnavailableDeviceKeyStore(),
            (_, _) => throw new InvalidOperationException("unreachable"),
            TimeProvider.System,
            ssh,
            CheapProfile);

        await using var _ = offline.ConfigureAwait(false);

        await offline.StartAsync(Token);
        offline.Passphrase = Passphrase;
        await offline.UnlockCommand.ExecuteAsync(null);

        var vault = offline.Vault!;
        await AddHostAsync(vault, "offline-host");

        await vault.SyncCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("Offline");
        vault.PendingChanges.ShouldBe(1, "the change is kept, not discarded");
        server.PushCount.ShouldBe(0);
    }

    /// <remarks>
    /// The one connect test that deliberately attaches no <see cref="FakeRenderer"/>: the listener is up
    /// and nothing ever connects to it, which from the view model's side is indistinguishable from a
    /// WebView2 that failed to initialise on a user's machine. The interesting assertion is the second
    /// one: while the wait was unbounded this hung with the busy flag set, so the window stayed disabled
    /// and said "Connecting…" for the rest of the session.
    /// </remarks>
    [Fact]
    public async Task ConnectingWithNoRenderer_ExplainsItselfAndReleasesTheWindow()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];

        await vault.ConnectCommand.ExecuteAsync(null);

        // Naming the runtime is the whole point: a bare "The operation has timed out" sends someone
        // looking at their network or their host.
        vault.Status.ShouldContain("WebView2");
        vault.IsBusy.ShouldBeFalse("a connect that gave up must not leave the window disabled");
    }

    [Fact]
    public async Task LockingForgetsTheVault()
    {
        await UnlockedAsync();

        await shell.LockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Locked);
        shell.Vault.ShouldBeNull();

        // And unlocking again works, so locking released rather than corrupted anything.
        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);
        shell.State.ShouldBe(ShellState.Unlocked);
    }

    /// <summary>
    /// The deliberate half of what Lock does: the vault closes, the shells do not.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A policy rather than an implementation detail, which is why it is asserted here. Locking is what a
    /// person does when they walk away from the machine, and that is exactly when a long job is most
    /// likely to be running — so ending every shell would make Lock destroy work, and an idle auto-lock
    /// would do it unattended. <c>MainWindowViewModel.LockAsync</c> carries the full argument.
    /// </para>
    /// <para>
    /// The disclosure is asserted along with the behaviour, because the two are the same decision. A
    /// lock screen that hides the terminal — which it does, the WebView is collapsed while locked — while
    /// authenticated SSH channels stay open is only defensible if it says so.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task LockingKeepsOpenShellsRunning_AndSaysSoOnTheUnlockScreen()
    {
        await UnlockedAsync();

        // Opened on the workspace rather than through Connect. Connect would work here — FakeRenderer can
        // satisfy the renderer gate — but what is under test is what Lock does to a session that exists,
        // not how it came to exist, and going through the gate would only add a way for this to fail.
        await workspace.OpenSessionAsync(
            new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
            TerminalSize.Default,
            progress: null,
            Token);

        workspace.LiveSessionCount.ShouldBe(1);

        await shell.LockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Locked);
        shell.Vault.ShouldBeNull("the vault's keys are gone");

        workspace.LiveSessionCount.ShouldBe(1, "the shell was still running, so it kept running");

        shell.HasLiveSessions.ShouldBeTrue();
        shell.LiveSessionCount.ShouldBe(1);
        shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");

        // And it survives the unlock too, so the session outlives the whole cycle rather than merely
        // outliving the disposal.
        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked);
        workspace.LiveSessionCount.ShouldBe(1);
    }

    [Fact]
    public async Task LockingWithNoOpenShells_DisclosesNothing()
    {
        await UnlockedAsync();

        await shell.LockCommand.ExecuteAsync(null);

        shell.LiveSessionCount.ShouldBe(0);
        shell.HasLiveSessions.ShouldBeFalse("an ordinary lock must not warn about nothing");
    }

    // ---- SSH keys ----

    [Fact]
    public async Task AddingAKey_ShowsItImmediatelyAndPushesIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewKeyCommand.Execute(null);
        vault.IsEditingKey.ShouldBeTrue();

        vault.KeyEditorLabel = "deploy";
        vault.KeyEditorPrivateKey = PrivateKey("MATERIAL");
        vault.KeyEditorPassphrase = "hunter2";

        await vault.SaveKeyCommand.ExecuteAsync(null);

        vault.IsEditingKey.ShouldBeFalse();

        var row = vault.Keys.ShouldHaveSingleItem();
        row.Label.ShouldBe("deploy");
        row.Description.ShouldBe("passphrase · no public half");
        row.HasUnsyncedChanges.ShouldBeFalse("saving pushes, so nothing should still be pending");

        vault.PendingChanges.ShouldBe(0);
        server.LiveRowCount.ShouldBe(1, "a saved key should reach the server without pressing Sync");

        // And the host list is untouched, so the two lists are genuinely separate.
        vault.Hosts.ShouldBeEmpty();
    }

    [Fact]
    public async Task AddingAKey_BindsItToNothing()
    {
        // Storing a key must not change how any host authenticates. The failure this rules out is a key
        // nobody chose being offered to a host — a credential leaving the vault by accident.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddKeyAsync(vault, "deploy");

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
        vault.Hosts[0].Authentication.ShouldBe("password");

        await vault.SyncCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
    }

    [Fact]
    public async Task EditingAKey_RoundTripsThroughTheEditorIncludingTheMaterial()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await vault.SyncCommand.ExecuteAsync(null);

        vault.SelectedKey = vault.Keys[0];
        vault.EditSelectedKeyCommand.Execute(null);

        // The material has to come back into the editor. The codec has no partial update, so a save
        // re-encodes every field — an editor that loaded a blank private key would erase it.
        vault.KeyEditorLabel.ShouldBe("deploy");
        vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("MATERIAL"));
        vault.KeyEditorPassphrase.ShouldBe("hunter2");

        vault.KeyEditorNotes = "rotate quarterly";
        await vault.SaveKeyCommand.ExecuteAsync(null);
        await vault.SyncCommand.ExecuteAsync(null);

        var saved = vault.Keys.ShouldHaveSingleItem().Key;
        saved.Notes.ShouldBe("rotate quarterly");
        saved.PrivateKeyPem.ShouldBe(PrivateKey("MATERIAL"));
        saved.Passphrase.ShouldBe("hunter2");
    }

    [Fact]
    public async Task CancellingTheKeyEditor_LeavesNoMaterialBehindInIt()
    {
        // The editor holds a private key in a bound property for as long as it is open. It cannot be wiped
        // — see SshKeySecret — but it can stop being referenced, and an abandoned editor that kept the key
        // would hand it to whatever opened next.
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorLabel = "deploy";
        vault.KeyEditorPrivateKey = PrivateKey("ABANDONED");
        vault.KeyEditorPassphrase = "hunter2";

        vault.CancelKeyEditCommand.Execute(null);

        vault.IsEditingKey.ShouldBeFalse();
        vault.KeyEditorPrivateKey.ShouldBeEmpty();
        vault.KeyEditorPassphrase.ShouldBeEmpty();
        vault.KeyEditorLabel.ShouldBeEmpty();
        vault.Keys.ShouldBeEmpty();
    }

    [Fact]
    public async Task APublicKeyPastedIntoThePrivateField_NamesTheActualMistake()
    {
        // ssh-keygen writes two files whose names differ by four characters. The message has to say which
        // one to pick, because the alternative is an authentication failure at connect time that says
        // nothing about the file.
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorLabel = "deploy";
        vault.KeyEditorPrivateKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 deploy@laptop";

        await vault.SaveKeyCommand.ExecuteAsync(null);

        vault.Status.ShouldContain(".pub");
        vault.Keys.ShouldBeEmpty();
        vault.IsEditingKey.ShouldBeTrue("the editor stays open so the paste can be corrected");
    }

    [Fact]
    public async Task DeletingAKey_RemovesItLocallyAndPushesTheTombstone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");

        vault.SelectedKey = vault.Keys[0];
        await DeleteSelectedKeyAsync(vault);

        vault.Keys.ShouldBeEmpty();
        server.LiveRowCount.ShouldBe(0);
        vault.PendingChanges.ShouldBe(0);
    }

    // ---- One kind of item at a time ----

    /// <remarks>
    /// Hosts are not one of these any more. They have their own screen beside the terminal, which is what the
    /// design asks for and is the better split anyway: the host list is what you look at while you work, and
    /// the keys and passwords behind it are what you go and manage. What is left here is the vault screen's
    /// own rail, and it opens on everything at once because the categories are a filter over one table
    /// rather than four separate lists.
    /// </remarks>
    [Fact]
    public async Task TheKeychainScreenOpensOnEverythingAndTheRailMovesBetweenCategories()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.Section.ShouldBe(VaultSection.All);
        vault.ShowsAll.ShouldBeTrue();
        vault.ShowsKeys.ShouldBeFalse();

        vault.ShowSectionCommand.Execute(VaultSection.Keys);

        vault.ShowsKeys.ShouldBeTrue();
        vault.ShowsAll.ShouldBeFalse("both flags are one fact read two ways and cannot both be true");

        vault.ShowSectionCommand.Execute(VaultSection.All);
        vault.ShowsAll.ShouldBeTrue();
    }

    /// <remarks>
    /// The merged category is what the design's one credential table exists for, and the thing worth pinning
    /// about it is that it is a projection rather than a fifth list: every row maps back to the typed row the
    /// editors and the delete commands already act on.
    /// </remarks>
    [Fact]
    public async Task TheMergedTableCarriesEveryKindAndSelectingARowSelectsTheTypedOne()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "pg-primary", "s3cret");

        // Adding leaves the rail on whatever was added last, because opening an editor brings its own
        // category into view. Back to the merged one, which is where the screen opens.
        vault.ShowSectionCommand.Execute(VaultSection.All);

        vault.ShowsAll.ShouldBeTrue();
        vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy", "pg-primary"]);
        vault.VaultItems.Select(row => row.Type).ShouldBe(["SSH KEY", "PASSWORD"]);

        vault.SelectedVaultItem = vault.VaultItems.First(row => row.Kind is VaultItemKind.Credential);

        vault.SelectedCredential.ShouldNotBeNull();
        vault.SelectedCredential.Label.ShouldBe("pg-primary");
        vault.SelectedItemIsEditable.ShouldBeTrue();

        // And narrowing to one kind does not disturb what is selected underneath.
        vault.ShowSectionCommand.Execute(VaultSection.Keys);

        vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy"]);
        vault.SelectedCredential.Label.ShouldBe("pg-primary", "narrowing the view is not a deselection");
    }

    /// <remarks>
    /// The invariant that makes an unreachable editor impossible: an editor is only ever open in the section
    /// that is showing. Without it, adding a key from a keyboard shortcut or a future menu would open an
    /// editor nobody can see, holding a private key nobody can cancel.
    /// </remarks>
    [Fact]
    public async Task OpeningAnEditorBringsItsOwnSectionIntoView()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddKeyAsync(vault, "deploy");

        vault.Section = VaultSection.All;
        vault.NewKeyCommand.Execute(null);
        vault.ShowsKeys.ShouldBeTrue("the key editor cannot be open with the rail pointing elsewhere");
        vault.CancelKeyEditCommand.Execute(null);

        // And through the other door into the editor.
        vault.Section = VaultSection.All;
        vault.SelectedKey = vault.Keys[0];
        vault.EditSelectedKeyCommand.Execute(null);
        vault.ShowsKeys.ShouldBeTrue();
        vault.CancelKeyEditCommand.Execute(null);

        // The host editor is the exemption, and it is the point of the split: hosts are a screen of their
        // own, so opening their editor has no category to bring into view and must not move the rail.
        vault.Section = VaultSection.Keys;
        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);
        vault.IsEditing.ShouldBeTrue();
        vault.ShowsKeys.ShouldBeTrue("editing a host is not a reason to move the vault screen's rail");
    }

    /// <remarks>
    /// The refusal that keeps the rule above true. Leaving the section while a vault-screen editor is open
    /// would hide it, and in the key editor's case that means a pasted private key sitting in a form with
    /// nothing on screen to say it is there.
    /// </remarks>
    [Fact]
    public async Task SwitchingSectionIsRefusedWhileAKeychainScreenEditorIsOpen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");

        vault.ShowSectionCommand.Execute(VaultSection.All);

        vault.ShowsKeys.ShouldBeTrue("the selector must not move away from an open editor");
        vault.Status.ShouldContain("SSH key");
        vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));

        // A refusal, not a lockout: dealing with the editor releases the selector.
        vault.CancelKeyEditCommand.Execute(null);
        vault.ShowSectionCommand.Execute(VaultSection.All);
        vault.ShowsAll.ShouldBeTrue();
    }

    /// <remarks>
    /// The host editor lives on a different screen from the rail, so it does not guard the rail — nothing
    /// about a half-typed host is visible or at risk from switching the vault screen's own category, and
    /// blocking it here used to leave three quarters of that screen inert with a message pointing at an
    /// editor the user could not see.
    /// </remarks>
    [Fact]
    public async Task SwitchingSectionIsNotBlockedByAnOpenHostEditor()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "half-typed";

        vault.ShowSectionCommand.Execute(VaultSection.Keys);

        vault.ShowsKeys.ShouldBeTrue("a host editor on another screen has nothing to say about this rail");
        vault.IsEditing.ShouldBeTrue("switching category must not close the host editor either");
        vault.EditorLabel.ShouldBe("half-typed");
    }

    /// <remarks>
    /// Asking for the section that is already showing is not a refusal, so a second click on the selected
    /// button while an editor is open says nothing. Worth pinning because the obvious implementation — check
    /// the editor, then compare — would scold somebody for clicking where they already are.
    /// </remarks>
    [Fact]
    public async Task ReselectingTheSectionAlreadyShowingSaysNothing()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewHostCommand.Execute(null);
        vault.Status = string.Empty;

        vault.ShowSectionCommand.Execute(VaultSection.All);

        vault.Status.ShouldBeEmpty();
        vault.IsEditing.ShouldBeTrue();
    }

    /// <remarks>
    /// One editor open at a time <em>within</em> the vault screen, still — but no longer across it and the
    /// Hosts screen. Both editors used to be <c>Auto</c> rows in one 340-pixel column whose combined height
    /// exceeded it; sections ended that, and the design import gave hosts their own screen, so the host
    /// editor no longer shares any column, any visibility or any risk with the key and credential editors.
    /// What the rule still buys, inside the vault screen, is that an open key editor is always one somebody
    /// can see, because it is holding their private key.
    /// </remarks>
    [Fact]
    public async Task TheHostEditorAndAKeychainScreenEditorCanBeOpenTogether()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");

        vault.NewHostCommand.Execute(null);

        // Both open at once: the host editor is on a screen of its own, and there is nothing for it to
        // clip or hide on the vault screen the key editor is on.
        vault.IsEditing.ShouldBeTrue("the host editor is a different screen's business now");
        vault.IsEditingKey.ShouldBeTrue("and does not close the vault screen's own editor");
        vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));
    }

    /// <remarks>
    /// One vault-screen editor at a time, still. The key and credential editors share the same screen and
    /// the same detail pane, so opening one over the other is exactly the case the rule exists for — unlike
    /// the host editor, which does not.
    /// </remarks>
    [Fact]
    public async Task OnlyOneKeychainScreenEditorOpensAtATime_AndTheRefusalKeepsWhatWasTyped()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorPrivateKey = PrivateKey("PASTED-AND-NOWHERE-ELSE");

        vault.NewCredentialCommand.Execute(null);

        vault.IsEditingCredential.ShouldBeFalse("the credential editor must not open over the key editor");
        vault.IsEditingKey.ShouldBeTrue();
        vault.Status.ShouldContain("SSH key");

        // The refusal is worth nothing if it costs the paste.
        vault.KeyEditorPrivateKey.ShouldBe(PrivateKey("PASTED-AND-NOWHERE-ELSE"));

        // And it is a refusal, not a lockout.
        vault.CancelKeyEditCommand.Execute(null);
        vault.NewCredentialCommand.Execute(null);
        vault.IsEditingCredential.ShouldBeTrue();

        // Symmetrically, with the credential editor holding the screen.
        vault.CredentialEditorPassword = "half-typed";
        vault.NewKeyCommand.Execute(null);

        vault.IsEditingKey.ShouldBeFalse();
        vault.CredentialEditorPassword.ShouldBe("half-typed");
        vault.Status.ShouldContain("credential");
    }

    [Fact]
    public async Task EditingAnExistingVaultItem_IsRefusedByTheOtherKeychainScreenEditorToo()
    {
        // The Edit commands are a second door into the same screen, and guarding only the Add ones would
        // leave it wide open.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "pg-primary", "s3cret");

        vault.SelectedKey = vault.Keys[0];
        vault.SelectedCredential = vault.Credentials[0];

        vault.NewKeyCommand.Execute(null);
        vault.EditSelectedCredentialCommand.Execute(null);
        vault.IsEditingCredential.ShouldBeFalse();

        vault.CancelKeyEditCommand.Execute(null);

        vault.NewCredentialCommand.Execute(null);
        vault.EditSelectedKeyCommand.Execute(null);
        vault.IsEditingKey.ShouldBeFalse();
    }

    /// <remarks>
    /// The host editor's own version of the same rule: opening a second host editor over the first, or
    /// editing an existing host while adding one, is refused — this is the one case the split guard still
    /// has to cover, because both doors lead to the same single editor on the Hosts screen.
    /// </remarks>
    [Fact]
    public async Task TheHostEditorRefusesToOpenOverItself()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "half-typed";

        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorLabel.ShouldBe("half-typed", "the second door must not discard the first editor's draft");
        vault.Status.ShouldContain("host");
    }

    /// <remarks>
    /// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono
    /// count beside every category row, and this pins that each one is the same number the category's own
    /// list already carries rather than a second, hand-kept tally that could drift from it.
    /// </remarks>
    [Fact]
    public async Task TheCategoryRailCountsMatchTheUnderlyingLists()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddKeyAsync(vault, "backup");
        await AddCredentialAsync(vault, "pg-primary", "s3cret");
        await AddTagAsync(vault, "production");

        vault.Keys.Count.ShouldBe(2);
        vault.Credentials.Count.ShouldBe(1);
        vault.Tags.Count.ShouldBe(1);
        vault.ObjectStores.Count.ShouldBe(0);
        vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included");
    }

    /// <remarks>
    /// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a
    /// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance —
    /// which is the whole reason the column exists.
    /// </remarks>
    [Fact]
    public async Task EachVaultItemKindCarriesItsOwnGlyph()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "pg-primary", "s3cret");
        await AddTagAsync(vault, "production");

        vault.ShowSectionCommand.Execute(VaultSection.All);

        var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph);

        byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty();
        byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty();
        byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty();

        new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] }
            .Distinct(StringComparer.Ordinal).Count()
            .ShouldBe(3, "three kinds on the same table read as three different glyphs");
    }

    /// <remarks>
    /// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had
    /// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and
    /// clearing it brings every row straight back.
    /// </remarks>
    [Fact]
    public async Task TheItemFilterNarrowsTheMergedTableByNameOrType()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy-key");
        await AddCredentialAsync(vault, "pg-primary", "s3cret");
        vault.ShowSectionCommand.Execute(VaultSection.All);

        vault.ItemFilter = "deploy";

        vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]);

        vault.ItemFilter = "PASSWORD";

        vault.VaultItems.Select(row => row.Name).ShouldBe(
            ["pg-primary"], "the type word matches too, case-insensitively");

        vault.ItemFilter = string.Empty;

        vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion");
    }

    /// <remarks>
    /// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all
    /// three read the same resolved-binding scan <c>HostsBoundTo</c> already used for the deletion warning,
    /// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of
    /// them going stale relative to the others.
    /// </remarks>
    [Fact]
    public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-api-01");
        await AddKeyAsync(vault, "deploy");

        var key = vault.Keys[0];
        await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId);

        vault.ShowSectionCommand.Execute(VaultSection.All);
        var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key);

        row.UsedBySummary.ShouldBe("prod-api-01");
        row.HasUsedBySummary.ShouldBeTrue();

        vault.SelectedVaultItem = row;

        vault.HasSelectedItemUsedByHosts.ShouldBeTrue();
        vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01");
        vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host");

        // And a key nothing authenticates with says none of this — never a zero-count claim.
        await AddKeyAsync(vault, "unused");
        vault.SelectedVaultItem = vault.VaultItems.Single(
            item => string.Equals(item.Name, "unused", StringComparison.Ordinal));

        vault.HasSelectedItemUsedByHosts.ShouldBeFalse();
        vault.SelectedItemInUseSummary.ShouldBe(string.Empty);
    }

    // ---- Keeping an open editor's pickers in step with the vault ----

    /// <remarks>
    /// The other side of the split guard, and the bug it left behind. The host editor and the keychain's
    /// editors are on different screens and refuse each other no longer, so a key is very often added
    /// <em>because</em> the host in front of the user needs one — with that host's editor still standing on the
    /// Hosts screen. The picker was a snapshot taken when the editor opened, so the key never appeared in it
    /// and the only way to reach it was to abandon the edit and start again.
    /// </remarks>
    [Fact]
    public async Task AKeyAddedWithTheHostEditorOpen_AppearsInItsAuthenticationPicker()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorPort = 2244;

        await AddKeyAsync(vault, "deploy");

        vault.IsEditing.ShouldBeTrue("adding a key must not close the host editor");
        vault.EditorPort.ShouldBe(2244, "nor discard what has been typed into it");

        var offered = vault.EditorAuthenticationChoices.Single(
            choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal));

        offered.Kind.ShouldBe(AuthenticationKind.SshKey);
        offered.EntityId.ShouldBe(vault.Keys[0].EntityId);

        // A real entry rather than a label: choosing it and saving is what the user came here to do.
        vault.EditorSelectedAuthentication = offered;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(vault.Keys[0].EntityId);
        vault.Hosts[0].Host.Port.ShouldBe(2244);
    }

    /// <remarks>
    /// The counterweight, and the reason the refill restores each picker by id rather than reloading the
    /// stored host: a list that grows under somebody halfway through a form must not move what they had
    /// already chosen in it, and the save that follows must not rebind the host to something nobody picked.
    /// </remarks>
    [Fact]
    public async Task AnItemArrivingWhileTheHostEditorIsOpen_LeavesItsSelectionWhereItWas()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");

        var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;

        await BindKeyAsync(vault, vault.Hosts[0], keyId);

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        await AddCredentialAsync(vault, "pg-primary");

        vault.EditorAuthenticationChoices.ShouldContain(
            choice => choice.Kind == AuthenticationKind.Credential);

        vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId
            .ShouldBe(keyId, "a credential appearing must not unbind the host from its key");

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
        vault.Hosts[0].Host.CredentialId.ShouldBeNull();
    }

    /// <remarks>
    /// The moment a credential is wanted is the moment somebody is choosing how a host authenticates and
    /// finds it is not in the keychain yet, so the host editor makes one. Selecting it has to survive the
    /// reload the write triggers, which is the part that needs a test: the refill rebuilds the picker from
    /// the vault and restores it from the editor's own selection, so the binding is written before the
    /// reload rather than after it.
    /// </remarks>
    [Fact]
    public async Task ACredentialMadeInTheHostEditor_BindsTheHostToIt()
    {
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.BeginEditorCredentialCommand.Execute(null);
        vault.IsAddingEditorCredential.ShouldBeTrue();

        vault.EditorNewCredentialLabel = "pg-primary";
        vault.EditorNewCredentialUsername = "postgres";
        vault.EditorNewCredentialPassword = "s3cret";
        vault.EditorNewCredentialNotes = "rotated quarterly";

        await vault.AddEditorCredentialCommand.ExecuteAsync(null);

        var credential = vault.Credentials.ShouldHaveSingleItem();
        credential.Credential.Username.ShouldBe("postgres");
        credential.Credential.Notes.ShouldBe("rotated quarterly");

        vault.IsAddingEditorCredential.ShouldBeFalse("the form closes once the credential is in the keychain");
        vault.EditorNewCredentialPassword.ShouldBeEmpty("the form must not go on holding the password");

        vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(
            credential.EntityId,
            "the picker has to land on the credential that was just made, through the reload");

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credential.EntityId);
        vault.Hosts[0].Host.SshKeyId.ShouldBeNull();
    }

    /// <remarks>
    /// The honest consequence of writing immediately, and the same one the new-tag box already carries: a
    /// credential is a shared item with an id, the host can only name an id that exists, so the credential
    /// was never part of the host to begin with. What was still being typed is a different matter — that
    /// includes a password, and it goes with the editor it was typed into.
    /// </remarks>
    [Fact]
    public async Task CancellingTheHostEditor_KeepsTheCredentialItMade_AndDropsWhatWasStillBeingTyped()
    {
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.BeginEditorCredentialCommand.Execute(null);
        vault.EditorNewCredentialLabel = "pg-primary";
        vault.EditorNewCredentialPassword = "s3cret";

        await vault.AddEditorCredentialCommand.ExecuteAsync(null);

        // A second one, opened and left half-typed.
        vault.BeginEditorCredentialCommand.Execute(null);
        vault.EditorNewCredentialLabel = "half";
        vault.EditorNewCredentialPassword = "typed-but-never-added";
        vault.EditorNewCredentialNotes = "half a thought";

        vault.CancelEditCommand.Execute(null);

        vault.Credentials.ShouldHaveSingleItem().Label.ShouldBe("pg-primary");

        vault.IsAddingEditorCredential.ShouldBeFalse();
        vault.EditorNewCredentialLabel.ShouldBeEmpty();
        vault.EditorNewCredentialNotes.ShouldBeEmpty();
        vault.EditorNewCredentialPassword.ShouldBeEmpty(
            "a password typed into an abandoned form must not survive behind the next host");

        vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(
            "the binding itself was never saved");
    }

    /// <remarks>
    /// Why this form has fields of its own rather than reusing the keychain screen's four.
    /// <c>IsEditingCredential</c> is what <c>AVaultEditorIsInTheWay</c> asks about, so sharing it would make
    /// the whole Vault screen refuse to open an editor, with a sentence naming a form the user cannot see
    /// on a screen they are not looking at. That is the exact failure the guard was split in two to end.
    /// </remarks>
    [Fact]
    public async Task TheHostEditorsCredentialForm_DoesNotBlockTheKeychainsOwnEditors()
    {
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);
        vault.BeginEditorCredentialCommand.Execute(null);

        vault.NewCredentialCommand.Execute(null);

        vault.IsEditingCredential.ShouldBeTrue(
            "the keychain's editor lives on another screen and opens regardless");
    }

    /// <remarks>
    /// Where this deliberately parts from the new-tag box beside it, which offers an existing tag rather
    /// than repeating it. Two tags called "staging" are one intention spelled twice; two credentials called
    /// "root" are two different passwords, and quietly binding the host to whichever was there already
    /// would authenticate it as an account nobody chose.
    /// </remarks>
    [Fact]
    public async Task ACredentialMadeInTheHostEditor_UnderANameAlreadyTaken_IsASecondCredential()
    {
        var vault = await ReadyToConnectAsync();
        await AddCredentialAsync(vault, "root", password: "first");

        var first = vault.Credentials.ShouldHaveSingleItem().EntityId;

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.BeginEditorCredentialCommand.Execute(null);
        vault.EditorNewCredentialLabel = "root";
        vault.EditorNewCredentialPassword = "second";

        await vault.AddEditorCredentialCommand.ExecuteAsync(null);

        vault.Credentials.Count.ShouldBe(2);

        vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldNotBe(
            first,
            "binding to the credential that happened to share the name would be the wrong password");
    }

    /// <remarks>
    /// The same refusal <c>CredentialSecret.TryValidate</c> gives the keychain's editor, reaching the user
    /// here rather than producing an item that looks usable and fails at the handshake.
    /// </remarks>
    [Fact]
    public async Task ACredentialMadeInTheHostEditor_WithNoPassword_IsRefused()
    {
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.BeginEditorCredentialCommand.Execute(null);
        vault.EditorNewCredentialLabel = "pg-primary";

        await vault.AddEditorCredentialCommand.ExecuteAsync(null);

        vault.Credentials.ShouldBeEmpty();
        vault.IsAddingEditorCredential.ShouldBeTrue("the form stays open on what it refused");
        vault.Status.ShouldContain("password");
    }

    /// <remarks>
    /// Tags reach the same editor by a different route — the keychain screen rather than the box under the
    /// chips — and a chip that only appeared on the next open would send the user round the same detour.
    /// </remarks>
    [Fact]
    public async Task ATagAddedWithTheHostEditorOpen_AppearsAmongItsChips()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        await AddTagAsync(vault, "production");

        vault.HasTagChoices.ShouldBeTrue();

        var chip = vault.EditorTagChoices.ShouldHaveSingleItem();
        chip.Label.ShouldBe("production");
        chip.IsWorn.ShouldBeFalse("appearing is not the same as being put on");
    }

    /// <remarks>
    /// The group editor shares the drawer with the host editor and its own picker was the same snapshot, so
    /// the same detour applied to the default binding a whole group of hosts inherits.
    /// </remarks>
    [Fact]
    public async Task AKeyAddedWithTheGroupEditorOpen_AppearsInItsDefaultBindingPicker()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddGroupAsync(vault, "platform");

        vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
        vault.GroupEditorDefaultPort = 2222;

        await AddKeyAsync(vault, "deploy");

        vault.IsEditingGroup.ShouldBeTrue("adding a key must not close the group editor");
        vault.GroupEditorDefaultPort.ShouldBe(2222, "nor discard what has been typed into it");

        vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single(
            choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal));

        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.Groups.ShouldHaveSingleItem().Group.DefaultSshKeyId.ShouldBe(vault.Keys[0].EntityId);
        vault.Groups[0].Group.DefaultPort.ShouldBe(2222);
    }

    // ---- Binding a key to a host ----

    [Fact]
    public async Task BindingAKeyToAHost_RoundTripsThroughTheEditorAndTheVault()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");

        var keyId = vault.Keys[0].EntityId;

        await BindKeyAsync(vault, vault.Hosts[0], keyId);

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);
        vault.Hosts[0].Authentication.ShouldBe("key");

        // Through the server and back, which is what makes it a property of the host rather than of this
        // machine — the whole reason it is a payload field and not a local preference.
        await vault.SyncCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(keyId);

        // And it is offered back correctly when the editor reopens, including as the current selection.
        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(keyId);
        vault.EditorAuthenticationChoices[0].Kind
            .ShouldBe(AuthenticationKind.Typed, "the typed-password entry stays first");
    }

    [Fact]
    public async Task AHostWithNoKey_UsesThePassword()
    {
        var vault = await ReadyToConnectAsync();

        // A key exists in the vault and is even selected in the key list. An unbound host must still use
        // the password: the list selection is for editing keys, not for deciding authentication.
        await AddKeyAsync(vault, "deploy");
        vault.SelectedKey = vault.Keys[0];
        vault.ConnectPassword = "typed-in";

        await ConnectWithRendererAsync(vault);

        ssh.Requests.ShouldHaveSingleItem().Credential
            .ShouldBeOfType<SshPasswordCredential>()
            .Password.ShouldBe("typed-in");
    }

    [Fact]
    public async Task AHostBoundToAKey_HandsTheSshStackTheKeyAndItsPassphrase()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        vault.ConnectPassword = "should-not-be-used";

        await ConnectWithRendererAsync(vault);

        var credential = ssh.Requests.ShouldHaveSingleItem().Credential
            .ShouldBeOfType<SshPrivateKeyCredential>();

        System.Text.Encoding.UTF8.GetString(credential.PrivateKeyPem)
            .ShouldBe(PrivateKey("MATERIAL"));

        credential.Passphrase.ShouldBe("hunter2");
    }

    [Fact]
    public async Task AKeyWithABlankPassphraseBox_IsAKeyWithNoPassphrase()
    {
        // Blank and absent are one state, from the editor all the way to the credential. The list has to say
        // so too, because "passphrase" against a key that has none sends someone hunting for one they never
        // set — and SSH.NET will not correct them: it ignores a passphrase on an unprotected key rather than
        // refusing it. See docs/platform-flags.md.
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy", passphrase: string.Empty);

        vault.Keys.ShouldHaveSingleItem().Description.ShouldStartWith("no passphrase");

        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
        await ConnectWithRendererAsync(vault);

        ssh.Requests.ShouldHaveSingleItem().Credential
            .ShouldBeOfType<SshPrivateKeyCredential>()
            .Passphrase.ShouldBeNull();
    }

    [Fact]
    public async Task AHostWhoseKeyHasBeenDeleted_RefusesRatherThanFallingBackToThePassword()
    {
        // A key deleted on another machine is ordinary, and this is what it must not cause: a host somebody
        // deliberately set up for key-only access quietly starting to offer a password instead.
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        vault.SelectedKey = vault.Keys[0];
        await DeleteSelectedKeyAsync(vault);
        vault.Keys.ShouldBeEmpty();

        vault.SelectedHost = vault.Hosts[0];
        vault.ConnectPassword = "must-not-be-sent";

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
        vault.Status.ShouldContain("not in this keychain");
    }

    [Fact]
    public async Task EditingAHostWhoseKeyHasBeenDeleted_DoesNotQuietlyUnbindIt()
    {
        // The same failure one step removed, and the subtler one. Someone opens the host to change its port;
        // if the picker had silently fallen back to "no key", saving would convert it to password
        // authentication and nothing would ever have said so.
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");

        var keyId = vault.Keys[0].EntityId;
        await BindKeyAsync(vault, vault.Hosts[0], keyId);

        vault.SelectedKey = vault.Keys[0];
        await DeleteSelectedKeyAsync(vault);

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        // The binding is still there, still selected, and says what is wrong with it.
        var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
        selected.EntityId.ShouldBe(keyId);
        selected.Kind.ShouldBe(AuthenticationKind.SshKey, "a missing key must not come back as a credential");
        selected.Label.ShouldContain("no longer here");

        vault.EditorPort = 2244;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
        vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId, "an unrelated edit must not drop the binding");
    }

    [Fact]
    public async Task RemovingABinding_PutsTheHostBackOnAPassword()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
            .Single(choice => choice.Kind is AuthenticationKind.Typed);
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
        vault.Hosts[0].Authentication.ShouldBe("password");

        vault.ConnectPassword = "typed-in";
        await ConnectWithRendererAsync(vault);

        ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
    }

    // ---- Stored credentials ----

    [Fact]
    public async Task ACredentialRoundTripsThroughTheEditorAndTheVault()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");

        var row = vault.Credentials.ShouldHaveSingleItem();
        row.Label.ShouldBe("prod deploy");
        row.Description.ShouldBe("deploy", "the account is what the row has to show");

        // Through the server and back, which is the whole point of storing it in the vault rather than on the
        // machine that typed it.
        await vault.SyncCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);

        vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");

        vault.SelectedCredential = vault.Credentials[0];
        vault.EditSelectedCredentialCommand.Execute(null);

        vault.CredentialEditorPassword.ShouldBe(
            "s3cret", "the editor has to load it, because saving re-encodes every field");
        vault.CredentialEditorUsername.ShouldBe("deploy");
    }

    [Fact]
    public async Task ACredentialWithNoUsername_SaysItUsesTheHosts()
    {
        // Blank and absent are one state — CredentialSecret normalises them — and the row has to say which of
        // the two things a blank box means, because "no username" and "the host's username" are not the same
        // statement and only one of them is true.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "shared password", username: "   ");

        vault.Credentials.ShouldHaveSingleItem().Credential.Username.ShouldBeNull();
        vault.Credentials[0].Description.ShouldBe("uses each host's own username");
    }

    [Fact]
    public async Task ACredentialWithNoPassword_IsRefusedAndTheEditorStaysOpen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewCredentialCommand.Execute(null);
        vault.CredentialEditorLabel = "prod deploy";

        await vault.SaveCredentialCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("password");
        vault.Credentials.ShouldBeEmpty();
        vault.IsEditingCredential.ShouldBeTrue("the editor stays open so it can be filled in");
    }

    [Fact]
    public async Task CancellingTheCredentialEditor_LeavesNoPasswordBehindInIt()
    {
        // The same rule as the key editor, for the same reason: the editor holds the secret in a bound property
        // for as long as it is open, and an abandoned editor that kept it would hand it to whatever opened next.
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewCredentialCommand.Execute(null);
        vault.CredentialEditorLabel = "prod deploy";
        vault.CredentialEditorPassword = "ABANDONED";

        vault.CancelCredentialEditCommand.Execute(null);

        vault.IsEditingCredential.ShouldBeFalse();
        vault.CredentialEditorPassword.ShouldBeEmpty();
        vault.CredentialEditorLabel.ShouldBeEmpty();
        vault.Credentials.ShouldBeEmpty();
    }

    [Fact]
    public async Task DeletingACredential_RemovesItLocallyAndPushesTheTombstone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "prod deploy");

        vault.SelectedCredential = vault.Credentials[0];
        await DeleteSelectedCredentialAsync(vault);

        vault.Credentials.ShouldBeEmpty();
        server.LiveRowCount.ShouldBe(0);
        vault.PendingChanges.ShouldBe(0);
    }

    /// <remarks>
    /// A reload must not <em>invent</em> a selection, which is a safety property rather than a tidiness one:
    /// Delete acts on the selection, so a list that fell back to its first row would put a one-click deletion of
    /// somebody's password behind a button they never aimed. It does keep an existing selection, exactly as the
    /// host list does — losing it on every background sync would move the target out from under the user.
    /// </remarks>
    [Fact]
    public async Task ReloadingKeepsACredentialSelectionButNeverInventsOne()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddCredentialAsync(vault, "prod deploy");

        // Saving selects what was just saved, which is wanted, and a reload has to leave it alone.
        vault.SelectedCredential.ShouldNotBeNull();
        await vault.LoadAsync(Token);
        vault.SelectedCredential.ShouldNotBeNull();

        // Nothing selected is the state Delete must find nothing in.
        vault.SelectedCredential = null;
        await vault.LoadAsync(Token);

        vault.SelectedCredential.ShouldBeNull();

        // Explicitly rather than through the helper: with nothing selected there is nothing to ask about,
        // and the absence of a question is what proves the button found nothing to aim at.
        vault.DeleteCredentialCommand.Execute(null);
        vault.PendingDeletion.ShouldBeNull();

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Credentials.ShouldHaveSingleItem();
    }

    [Fact]
    public async Task TheVaultSummaryCountsCredentialsToo()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "prod deploy");

        await vault.LoadAsync(Token);

        vault.Status.ShouldBe("1 host(s), 1 key(s), 1 credential(s) in Personal.");

        // And a kind with nothing in it is left out rather than reported as zero.
        vault.SelectedCredential = vault.Credentials[0];
        await DeleteSelectedCredentialAsync(vault);
        await vault.LoadAsync(Token);

        vault.Status.ShouldBe("1 host(s), 1 key(s) in Personal.");
    }

    /// <remarks>
    /// What decides whether the terminal column shows a password box at all. Three states and only one of them
    /// wants typing, so this is the property that keeps a box from appearing on a host that has no use for one
    /// — and keeps the sentence in its place from claiming the wrong reason.
    /// </remarks>
    [Fact]
    public async Task ThePasswordBoxOnlyAppearsForAHostThatWillAskForOne()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "prod deploy");

        vault.SelectedHost = vault.Hosts[0];
        vault.SelectedHostAsksForAPassword.ShouldBeTrue();
        vault.SelectedHostAuthenticationNote.ShouldBeEmpty();

        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
        vault.SelectedHost = vault.Hosts[0];

        vault.SelectedHostAsksForAPassword.ShouldBeFalse();
        vault.SelectedHostAuthenticationNote.ShouldContain("SSH key");

        await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
        vault.SelectedHost = vault.Hosts[0];

        vault.SelectedHostAsksForAPassword.ShouldBeFalse();
        vault.SelectedHostAuthenticationNote.ShouldContain("stored in your keychain");
    }

    // ---- Authenticating with a stored credential ----

    [Fact]
    public async Task AHostBoundToACredential_SendsItsPasswordAndItsUsername()
    {
        // Both halves, and the username is the half that was easy to lose: a credential's whole reason for
        // existing is that it describes an account once, and sending its password under the host's username is
        // wrong in a way the server only reports as "authentication failed".
        //
        // The credential's username is deliberately *not* the host's — the host is on "deploy" — because the
        // two being equal is what makes this assertion pass under an implementation that reads the wrong one.
        var vault = await ReadyToConnectAsync();
        await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "svc-deploy");

        var credentialId = vault.Credentials[0].EntityId;
        await BindCredentialAsync(vault, vault.Hosts[0], credentialId);

        vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credentialId);
        vault.Hosts[0].Authentication.ShouldBe("credential");

        vault.ConnectPassword = "should-not-be-used";

        await ConnectWithRendererAsync(vault);

        var request = ssh.Requests.ShouldHaveSingleItem();
        request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
        request.Username.ShouldBe("svc-deploy", "the credential's account overrides the host's");
    }

    [Fact]
    public async Task ACredentialWithNoUsername_LeavesTheHostsInPlace()
    {
        // The shared-password case: one password used under whatever account each machine knows you by. The
        // fallback is what makes that expressible at all, and getting it backwards would send every connection
        // to the same account.
        var vault = await ReadyToConnectAsync();
        await AddCredentialAsync(vault, "shared password", password: "s3cret");

        await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);
        await ConnectWithRendererAsync(vault);

        var request = ssh.Requests.ShouldHaveSingleItem();
        request.Username.ShouldBe("deploy", "the host's own username, which ReadyToConnectAsync sets");
        request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
    }

    [Fact]
    public async Task AHostWithNoUsernameOfItsOwn_IsUsableThroughACredentialThatCarriesOne()
    {
        // The refusal for a host with no username used to run before anything looked at the binding, which made
        // a credential's username unreachable in exactly the case it was most useful: a host somebody never
        // filled a username in for.
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorUsername = string.Empty;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.Username.ShouldBeNull();

        await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");
        await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);

        await ConnectWithRendererAsync(vault);

        ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy");
    }

    [Fact]
    public async Task AHostWithNoUsernameAndNoCredential_IsStillRefusedAndSaysWhereToPutOne()
    {
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorUsername = string.Empty;
        await vault.SaveHostCommand.ExecuteAsync(null);

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldBeEmpty();
        vault.Status.ShouldContain("no username");
        vault.Status.ShouldContain("credential", Case.Insensitive);
    }

    [Fact]
    public async Task AHostWhoseCredentialHasBeenDeleted_RefusesRatherThanFallingBackToTheTypedPassword()
    {
        // The key case's twin, and it has to be its own test: the two branches are separate code, and the one
        // that was written second is the one nothing would have covered.
        var vault = await ReadyToConnectAsync();
        await AddCredentialAsync(vault, "prod deploy");
        await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);

        vault.SelectedCredential = vault.Credentials[0];
        await DeleteSelectedCredentialAsync(vault);
        vault.Credentials.ShouldBeEmpty();

        vault.SelectedHost = vault.Hosts[0];
        vault.ConnectPassword = "must-not-be-sent";

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldBeEmpty("nothing should have been dialled at all");
        vault.Status.ShouldContain("credential that is not in this keychain");
    }

    [Fact]
    public async Task EditingAHostWhoseCredentialHasBeenDeleted_DoesNotQuietlyUnbindIt()
    {
        var vault = await ReadyToConnectAsync();
        await AddCredentialAsync(vault, "prod deploy");

        var credentialId = vault.Credentials[0].EntityId;
        await BindCredentialAsync(vault, vault.Hosts[0], credentialId);

        vault.SelectedCredential = vault.Credentials[0];
        await DeleteSelectedCredentialAsync(vault);

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        var selected = vault.EditorSelectedAuthentication.ShouldNotBeNull();
        selected.EntityId.ShouldBe(credentialId);
        selected.Kind.ShouldBe(
            AuthenticationKind.Credential, "a missing credential must not come back as a missing key");
        selected.Label.ShouldContain("no longer here");

        vault.EditorPort = 2244;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem().Host.Port.ShouldBe(2244);
        vault.Hosts[0].Host.CredentialId.ShouldBe(credentialId, "an unrelated edit must not drop the binding");
    }

    // ---- Remembering a typed password ----

    [Fact]
    public async Task RememberingATypedPassword_BindsItToTheHostSoItIsNotAskedForAgain()
    {
        var vault = await ReadyToConnectAsync();

        vault.RemembersConnectPassword.ShouldBeFalse("storing a password stays a decision");

        vault.ConnectPassword = "s3cret";
        vault.RemembersConnectPassword = true;

        await ConnectAndRememberAsync(vault);

        // An ordinary keychain credential, named after the host, and carrying no username of its own — the
        // connection that just succeeded used the host's, and pinning a copy of it here would stop following
        // the host.
        var stored = vault.Credentials.ShouldHaveSingleItem();
        stored.Label.ShouldBe("prod-db");
        stored.Credential.Password.ShouldBe("s3cret");
        stored.Credential.Username.ShouldBeNull();

        var host = vault.Hosts.ShouldHaveSingleItem();
        host.Host.CredentialId.ShouldBe(stored.EntityId);
        host.Authentication.ShouldBe("credential");

        // The box has nothing left to hold and nothing left to ask, and the tick does not carry over to
        // whatever host is selected next.
        vault.ConnectPassword.ShouldBeEmpty();
        vault.RemembersConnectPassword.ShouldBeFalse();
        vault.SelectedHostAsksForAPassword.ShouldBeFalse();
    }

    [Fact]
    public async Task ARememberedPassword_SurvivesTheServerAndIsSentOnTheNextConnection()
    {
        // The whole point of storing it in the vault rather than on this machine: it is a property of the
        // host that reaches the other machines, not a box this one happens to remember filling in.
        var vault = await ReadyToConnectAsync();

        // One renderer for both connections. The page's token is spent on the first attach, so a second
        // FakeRenderer is answered with a 409 — which is the real renderer's behaviour too, and the reason
        // nothing else in this suite connects twice.
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ConnectPassword = "s3cret";
        vault.RemembersConnectPassword = true;

        await vault.ConnectCommand.ExecuteAsync(null);

        await vault.SyncCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);

        vault.Credentials.ShouldHaveSingleItem().Credential.Password.ShouldBe("s3cret");

        vault.SelectedHost = vault.Hosts[0];
        vault.ConnectPassword.ShouldBeEmpty("nothing should need typing now");

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.Count.ShouldBe(2);
        ssh.Requests[1].Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
    }

    [Fact]
    public async Task ARefusedConnection_RemembersNothing()
    {
        // The failure this feature could most easily cause: a typo bound to the host, which then stops asking
        // and cannot be connected to until somebody works out that the keychain is where the wrong password
        // now lives. Only a handshake the remote accepted is worth keeping.
        var vault = await ReadyToConnectAsync();

        ssh.Failure = new InvalidOperationException("authentication failed");

        vault.ConnectPassword = "wrong";
        vault.RemembersConnectPassword = true;

        await vault.ConnectCommand.ExecuteAsync(null);

        vault.Credentials.ShouldBeEmpty();
        vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
        vault.SelectedHostAsksForAPassword.ShouldBeTrue();
    }

    [Fact]
    public async Task ConnectingWithoutTheTick_StoresNothing()
    {
        // The other half of the decision, and the reason the typed box still exists: a one-off password on a
        // machine somebody will never open again must not end up synchronised to every device they own.
        var vault = await ReadyToConnectAsync();

        vault.ConnectPassword = "s3cret";

        await ConnectWithRendererAsync(vault);

        vault.Credentials.ShouldBeEmpty();
        vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
        vault.ConnectPassword.ShouldBe("s3cret", "the box is left as it was typed");
    }

    [Fact]
    public async Task RememberingIsIgnoredForAHostThatDoesNotAskForAPassword()
    {
        // A tick left over from a host that did ask must not manufacture a credential out of a stored one's
        // password — which is what reading the dialled secret without checking the binding would do.
        var vault = await ReadyToConnectAsync();
        await AddCredentialAsync(vault, "prod deploy", password: "s3cret");
        await BindCredentialAsync(vault, vault.Hosts[0], vault.Credentials[0].EntityId);

        vault.SelectedHost = vault.Hosts[0];
        vault.RemembersConnectPassword = true;

        await ConnectWithRendererAsync(vault);

        vault.Credentials.ShouldHaveSingleItem("nothing should have been added to the keychain");
    }

    /// <remarks>
    /// The reason the picker is one control rather than two. <c>HostSecret.TryValidate</c> refuses a host naming
    /// both a key and a credential, so two pickers would have been able to express the state and would have had
    /// to reject it at save time; one picker cannot express it. This asserts the structural version of that —
    /// that rebinding replaces rather than accumulates.
    /// </remarks>
    [Fact]
    public async Task RebindingFromAKeyToACredential_ReplacesTheBindingRatherThanAddingToIt()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "prod deploy", password: "s3cret", username: "deploy");

        var keyId = vault.Keys[0].EntityId;
        var credentialId = vault.Credentials[0].EntityId;

        await BindKeyAsync(vault, vault.Hosts[0], keyId);
        vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId);

        await BindCredentialAsync(vault, vault.Hosts[0], credentialId);

        var host = vault.Hosts.ShouldHaveSingleItem().Host;
        host.CredentialId.ShouldBe(credentialId);
        host.SshKeyId.ShouldBeNull("one picker means one binding");
        host.TryValidate(out _).ShouldBeTrue();

        // And back again, which is the direction that would leave a stale credential behind.
        await BindKeyAsync(vault, vault.Hosts[0], keyId);

        vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
        vault.Hosts[0].Host.SshKeyId.ShouldBe(keyId);
    }

    /// <remarks>
    /// A key and a credential the user has named the same thing is the ordinary case, not a contrived one — a
    /// key called <c>deploy</c> and the deploy account's password. Without the qualifier the picker offers two
    /// identical rows that authenticate completely differently.
    /// </remarks>
    [Fact]
    public async Task ThePickerDistinguishesAKeyAndACredentialWithTheSameName()
    {
        var vault = await ReadyToConnectAsync();
        await AddKeyAsync(vault, "deploy");
        await AddCredentialAsync(vault, "deploy");

        vault.SelectedHost = vault.Hosts[0];
        vault.EditSelectedHostCommand.Execute(null);

        var named = vault.EditorAuthenticationChoices
            .Where(choice => string.Equals(choice.Label, "deploy", StringComparison.Ordinal))
            .ToList();

        named.Count.ShouldBe(2);
        named.Select(choice => choice.Qualifier).ShouldBe(["SSH key", "credential"]);
    }

    /// <remarks>
    /// The credential editor guards the vault screen's own rail, exactly as the key editor does — but no
    /// longer the host editor, which is a different screen and has nothing to lose by the rail moving.
    /// </remarks>
    [Fact]
    public async Task TheCredentialEditorGuardsTheKeychainScreensRail()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewCredentialCommand.Execute(null);
        vault.CredentialEditorPassword = "half-typed";

        // The host editor opens freely: it is the Hosts screen's own business now.
        vault.NewHostCommand.Execute(null);
        vault.IsEditing.ShouldBeTrue("a different screen's editor is not this one's to refuse");
        vault.IsEditingCredential.ShouldBeTrue("and opening it must not have closed the credential editor");
        vault.CredentialEditorPassword.ShouldBe("half-typed");

        vault.ShowSectionCommand.Execute(VaultSection.All);
        vault.ShowsCredentials.ShouldBeTrue("the rail must not move away from an open vault-screen editor");

        vault.CancelCredentialEditCommand.Execute(null);
        vault.ShowSectionCommand.Execute(VaultSection.All);
        vault.ShowsAll.ShouldBeTrue();
    }

    // ---- Pinned host keys ----

    /// <remarks>
    /// The list that did not exist. Trust was created by the connect prompt and withdrawn from one host's
    /// editor, so a pin for a host that had since been deleted or re-addressed was unreachable from the
    /// interface entirely — it went on refusing connections, and nothing in the application would admit it
    /// was there.
    /// </remarks>
    [Fact]
    public async Task ThePinnedKeyList_ShowsWhatWasApprovedAndWhatNothingUsesAnyMore()
    {
        var vault = await ReadyToConnectAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
        await knownHosts.TrustAsync(
            new HostKeyPresentation("gone.internal", 22, "ssh-ed25519", "SHA256:another-key"), Token);

        // Pushed, as the connect path pushes a pin the moment it is approved. Without this both rows would
        // be badged "not synced", which is true and would drown out the badge this test is about.
        await vault.SyncCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);

        vault.KnownHostPins.Count.ShouldBe(2);

        var dialled = vault.KnownHostPins
            .Single(pin => string.Equals(pin.Host, "db.internal", StringComparison.Ordinal));
        dialled.IsDialledByAHost.ShouldBeTrue("ReadyToConnectAsync's host is at db.internal:22");
        dialled.Fingerprint.ShouldBe("SHA256:the-key", "in full, because that is what gets compared");
        dialled.Badge.ShouldBeEmpty();

        var orphan = vault.KnownHostPins
            .Single(pin => string.Equals(pin.Host, "gone.internal", StringComparison.Ordinal));
        orphan.IsDialledByAHost.ShouldBeFalse();
        orphan.Badge.ShouldBe("no host uses this");
    }

    [Fact]
    public async Task DeletingAHost_LeavesItsPinBehindAndTheListSaysSo()
    {
        // The behaviour the debt was about, now visible instead of silent. Keeping the pin is right — the
        // address may still be reached by something else, and trust is about the endpoint rather than the
        // bookmark — so the fix was never to cascade the delete. It was to stop the leftover being invisible.
        var vault = await ReadyToConnectAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);

        await vault.LoadAsync(Token);
        vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeTrue();

        vault.SelectedHost = vault.Hosts[0];
        await DeleteSelectedHostAsync(vault);

        vault.KnownHostPins.ShouldHaveSingleItem().IsDialledByAHost.ShouldBeFalse(
            "the pin outlives the host, and the list has to admit it");
    }

    [Fact]
    public async Task ForgettingAPinFromTheList_WithdrawsEveryKeyForThatAddress()
    {
        // One address, two algorithms, one decision. Somebody withdrawing trust from a machine has not
        // decided to keep trusting one of its keys — and a pin left behind would go on being offered at the
        // next handshake, which reads as a withdrawal that did not work.
        var vault = await ReadyToConnectAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-ed25519-key"), Token);
        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ecdsa-sha2-nistp256", "SHA256:the-ecdsa-key"), Token);

        await vault.LoadAsync(Token);
        vault.KnownHostPins.Count.ShouldBe(2);

        vault.SelectedKnownHost = vault.KnownHostPins[0];
        await vault.ForgetPinCommand.ExecuteAsync(null);

        vault.KnownHostPins.ShouldBeEmpty();
        vault.Status.ShouldContain("2 pinned key(s)");

        // And it reached the vault, not just the snapshot: the next connection has to ask again.
        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();

        // Pushed straight away, as trusting is — the other machines are the ones still refusing.
        vault.PendingChanges.ShouldBe(0);
    }

    [Fact]
    public async Task ForgettingWithNothingSelected_DoesNothing()
    {
        var vault = await ReadyToConnectAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);

        await vault.LoadAsync(Token);
        vault.SelectedKnownHost.ShouldBeNull("loading must not select a pin, because Forget acts on it");

        await vault.ForgetPinCommand.ExecuteAsync(null);

        vault.KnownHostPins.ShouldHaveSingleItem();
    }

    /// <remarks>
    /// v5c-3: fingerprints are public — operators publish theirs on purpose — so this is the one clipboard
    /// copy on this screen that needs no confirmation and no refusal, unlike a private key's own
    /// <c>CopyPublicKeyCommand</c>. In full, because a shortened fingerprint cannot be compared against what
    /// was published.
    /// </remarks>
    [Fact]
    public async Task CopyingAPinsFingerprint_PutsTheFullFingerprintOnTheClipboard()
    {
        var vault = await ReadyToConnectAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);

        await vault.LoadAsync(Token);
        vault.SelectedKnownHost = vault.KnownHostPins.ShouldHaveSingleItem();

        await vault.CopyPinFingerprintCommand.ExecuteAsync(null);

        clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
    }

    /// <remarks>The v5c screen's own restyle over <see cref="KnownHostsViewModel"/> forwards the same command.</remarks>
    [Fact]
    public async Task CopyingAPinsFingerprintThroughTheKnownHostsScreen_ReachesTheVault()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
        await vault.LoadAsync(Token);

        var pins = shell.KnownHostsScreen.ShouldNotBeNull();
        pins.Selected = pins.VisiblePins.ShouldHaveSingleItem();

        await pins.CopyFingerprintCommand.ExecuteAsync(null);

        clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
    }

    /// <remarks>
    /// The v5c header's own back arrow, reached through the same onBack delegate ImportViewModel's Cancel
    /// button uses — see MainWindowViewModel.OnVaultChanged. Its destination is the Keychain screen this list
    /// was pulled out of.
    /// </remarks>
    [Fact]
    public async Task TheKnownHostsScreensBackArrow_ReturnsToKeychain()
    {
        await UnlockedAsync();

        shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
        shell.IsKnownHostsScreen.ShouldBeTrue();

        var pins = shell.KnownHostsScreen.ShouldNotBeNull();
        pins.BackCommand.Execute(null);

        shell.IsKeychainScreen.ShouldBeTrue();
    }

    /// <remarks>
    /// Pins used to be a category on the keychain screen. They are a destination of their own now, and this
    /// is the seam that could silently come apart: the screen's view model is built from the vault in
    /// <c>OnVaultChanged</c>, so a vault opened by any path other than the one this test takes would leave
    /// the nav rail pointing at a null.
    /// </remarks>
    [Fact]
    public async Task ThePinsScreenExistsForAsLongAsTheKeychainDoes()
    {
        await UnlockedAsync();

        var pins = shell.KnownHostsScreen.ShouldNotBeNull("unlocking builds it");

        shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
        shell.IsKnownHostsShowing.ShouldBeTrue();
        shell.IsKeychainShowing.ShouldBeFalse();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
        await shell.Vault!.LoadAsync(Token);

        pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");

        await shell.LockCommand.ExecuteAsync(null);

        shell.KnownHostsScreen.ShouldBeNull("it goes with the keychain it was built from");
    }

    /// <remarks>
    /// The workflow the screen exists for: an operator publishes a fingerprint and somebody wants to know
    /// whether it is the one they approved. A filter that searched only host names would answer a different
    /// question, so this is the assertion that keeps the fingerprint in the search.
    /// </remarks>
    [Fact]
    public async Task ThePinsScreenFiltersByFingerprintAsWellAsByHost()
    {
        await UnlockedAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:aaaaaaaa"), Token);
        await knownHosts.TrustAsync(
            new HostKeyPresentation("web.internal", 22, "ssh-ed25519", "SHA256:bbbbbbbb"), Token);

        await shell.Vault!.LoadAsync(Token);

        var pins = shell.KnownHostsScreen!;
        pins.VisiblePins.Count.ShouldBe(2);

        pins.Filter = "bbbb";
        pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("web.internal");

        pins.Filter = "db.";
        pins.VisiblePins.ShouldHaveSingleItem().Host.ShouldBe("db.internal");

        pins.Filter = "nothing matches this";
        pins.VisiblePins.ShouldBeEmpty();
        pins.EmptyMessage.ShouldContain("matches that", Case.Insensitive);
    }

    /// <remarks>
    /// Forgetting is forwarded to the vault's command, which is the one wired into the reload and the push.
    /// What this covers is the forwarding: that the screen's own selection reaches it.
    /// </remarks>
    [Fact]
    public async Task ForgettingFromThePinsScreen_WithdrawsTheTrust()
    {
        await UnlockedAsync();

        await knownHosts.TrustAsync(
            new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
        await shell.Vault!.LoadAsync(Token);

        var pins = shell.KnownHostsScreen!;
        pins.Selected = pins.VisiblePins[0];

        await pins.ForgetSelectedCommand.ExecuteAsync(null);

        pins.VisiblePins.ShouldBeEmpty();
        (await knownHosts.FindAsync("db.internal", 22, "ssh-ed25519", Token)).ShouldBeNull();
    }

    // ---- Generating a key ----

    /// <remarks>
    /// The property that keeps this feature from being a second way to write a key: generating fills the
    /// editor and stops. Everything after that — validation, encoding, the outbox, the push — is the path a
    /// pasted key already takes, and SAVE is still the only thing that writes.
    /// </remarks>
    [Fact]
    public async Task GeneratingAKey_FillsTheEditorAndStoresNothing()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewGeneratedKeyCommand.Execute(null);
        vault.IsGeneratingKey.ShouldBeTrue();
        vault.GenerateComment = "deploy@laptop";

        await vault.GenerateKeyCommand.ExecuteAsync(null);

        vault.IsGeneratingKey.ShouldBeFalse();
        vault.IsEditingKey.ShouldBeTrue("what it made lands in the editor, unsaved");

        vault.KeyEditorLabel.ShouldBe("deploy@laptop");
        vault.KeyEditorPrivateKey.ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
        vault.KeyEditorPublicKey.ShouldStartWith("ssh-ed25519 ");
        vault.KeyEditorPublicKey.ShouldEndWith("deploy@laptop");

        vault.Keys.ShouldBeEmpty("nothing is stored until SAVE");
        vault.PendingChanges.ShouldBe(0);
        vault.Status.ShouldContain("SAVE");

        // And then it saves through the ordinary path, which is the other half of the claim.
        await vault.SaveKeyCommand.ExecuteAsync(null);

        vault.Keys.ShouldHaveSingleItem().Label.ShouldBe("deploy@laptop");
    }

    [Fact]
    public async Task CancellingTheGenerateForm_MakesNothing()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewGeneratedKeyCommand.Execute(null);
        vault.CancelGenerateKeyCommand.Execute(null);

        vault.IsGeneratingKey.ShouldBeFalse();
        vault.IsEditingKey.ShouldBeFalse();
        vault.Keys.ShouldBeEmpty();
    }

    /// <remarks>
    /// The public half and only the public half. There is deliberately no command for the other one — a
    /// private key on a clipboard is a private key in every application on the machine — so what this pins
    /// is that the one thing installing a key needs does reach the clipboard.
    /// </remarks>
    [Fact]
    public async Task CopyingAPublicKey_PutsThePublicHalfOnTheClipboard()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewGeneratedKeyCommand.Execute(null);
        await vault.GenerateKeyCommand.ExecuteAsync(null);
        await vault.SaveKeyCommand.ExecuteAsync(null);

        vault.Section = VaultSection.Keys;
        vault.SelectedVaultItem = vault.VaultItems[0];
        vault.SelectedItemIsKey.ShouldBeTrue();

        await vault.CopyPublicKeyCommand.ExecuteAsync(null);

        var copied = clipboard.ShouldHaveSingleItem();

        copied.ShouldStartWith("ssh-");
        copied.ShouldNotContain("PRIVATE KEY");
    }

    /// <remarks>
    /// ◆ <b>The one secret this application deliberately offers to the clipboard.</b> The recovery code
    /// exists for one screen, is stored nowhere and has to reach a password manager, so the clipboard is
    /// where it is going whatever the interface does — the only question is whether the interface helps or
    /// leaves somebody transcribing it, or photographing a screen that blocks screenshots.
    /// </remarks>
    [Fact]
    public async Task CopyingTheRecoveryCode_PutsItOnTheClipboardAndSaysWhereToPutIt()
    {
        await EnrolledAsync();

        shell.State.ShouldBe(ShellState.ShowingRecoveryCode);

        var code = shell.RecoveryCode.ShouldNotBeNull();

        await shell.CopyRecoveryCodeCommand.ExecuteAsync(null);

        clipboard.ShouldHaveSingleItem().ShouldBe(code);

        // The sentence matters as much as the copy. A clipboard is a staging post rather than a home, and
        // this screen is the only place the code exists — somebody who copies it and does nothing has not
        // saved it.
        shell.StatusMessage.ShouldContain("password manager");
        shell.StatusMessage.ShouldContain("replaces it");
    }

    /// <remarks>
    /// The other branch, and it needs a shell built without a clipboard because that is precisely the
    /// condition — the view model reads the delegate's absence, not an empty result. It must say so rather
    /// than leaving a button that appears to have worked: a recovery code somebody believes is on their
    /// clipboard is a recovery code they will not write down.
    /// </remarks>
    [Fact]
    public async Task CopyingTheRecoveryCode_WithNoClipboard_SaysSoRatherThanSeemingToWork()
    {
        var bare = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            knownHosts,
            deviceKeys,
            SignInAsync,
            TimeProvider.System,
            ssh,
            CheapProfile,
            ResumeAsync);

        await using (bare.ConfigureAwait(false))
        {
            bare.RecoveryCode = "correct horse battery staple";

            await bare.CopyRecoveryCodeCommand.ExecuteAsync(null);

            bare.StatusMessage.ShouldContain("no clipboard", Case.Insensitive);
            clipboard.ShouldBeEmpty("the other shell's clipboard must not have been written to either");
        }
    }

    // ---- Importing ssh_config ----

    /// <remarks>
    /// The whole of the import, from a file on disk to hosts on the server. What it establishes beyond the
    /// parser's own suite is the half that suite cannot reach: that scanning writes nothing, that importing
    /// goes through the ordinary create-and-push path, and that a host already in the keychain arrives
    /// unticked rather than being silently duplicated.
    /// </remarks>
    [Fact]
    public async Task ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        var sshDirectory = Path.Combine(directory, "ssh");
        Directory.CreateDirectory(sshDirectory);

        // db.internal:deploy is what AddHostAsync creates, so the first entry is a host already held.
        await File.WriteAllTextAsync(
            Path.Combine(sshDirectory, "config"),
            """
            Host already-here
              HostName db.internal
              User deploy

            Host web-01
              HostName web-01.internal
              User deploy
              Port 2222
            """,
            Token);

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.Rows.Count.ShouldBe(2);
        vault.Hosts.Count.ShouldBe(1, "scanning stores nothing");

        var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
        known.AlreadyPresent.ShouldBeTrue("it points at a machine the keychain already has");
        known.IsSelected.ShouldBeFalse("a duplicate takes a click rather than being the default");

        import.Rows
            .Single(row => string.Equals(row.Alias, "web-01", StringComparison.Ordinal))
            .IsSelected.ShouldBeTrue();

        await import.ImportCommand.ExecuteAsync(null);

        var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
        imported.Address.ShouldBe("deploy@web-01.internal:2222");

        vault.Hosts.Count.ShouldBe(2, "only the ticked one was stored");

        // Through the ordinary path, which is the point of routing it through the vault: it reached the
        // server without anything pressing Sync.
        server.LiveRowCount.ShouldBe(2);
    }

    /// <remarks>
    /// ◆ <b>The tick, and the half of it that matters is what happens with it off.</b> Reading a private key
    /// out of a home directory is the act this product exists to make deliberate, so the default has to be a
    /// default nobody arrives at by accident — and a scan that read keys in order to describe them would
    /// have already done the thing the tick gates. So this asserts twice over: nothing is bound, and the
    /// report that names every file it opened is empty.
    /// </remarks>
    [Fact]
    public async Task ImportingWithoutTheKeyTick_RecordsThePathAndReadsNothing()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = KeyedConfigDirectory();

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.HasKeyFiles.ShouldBeTrue("the tick is drawn only where there is a key to read");
        import.ImportsKeys.ShouldBeFalse("and it starts off");

        await import.ImportCommand.ExecuteAsync(null);

        vault.Keys.ShouldBeEmpty("nothing reached into ~/.ssh");
        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();
        import.KeyReport.ShouldBeEmpty("and no file was opened to report on");

        // The path is still recorded, which is what the import did before the tick existed.
        vault.Hosts[0].Host.Notes.ShouldContain("id_ed25519");
    }

    /// <remarks>
    /// The other side of it: with the tick on, the key is read, stored encrypted in the vault and bound to
    /// the host — which is the difference between an import whose result connects and one whose every host
    /// asks for a password.
    /// </remarks>
    [Fact]
    public async Task ImportingWithTheKeyTick_StoresTheKeyAndBindsTheHostToIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = KeyedConfigDirectory();

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.ImportsKeys = true;

        // The row says what it will do, which is the only place a person sees the tick's effect per host.
        import.Rows.ShouldHaveSingleItem().Authentication.ShouldContain("imported");

        await import.ImportCommand.ExecuteAsync(null);

        var key = vault.Keys.ShouldHaveSingleItem();

        key.Key.PrivateKeyPem.ShouldContain("PRIVATE KEY");
        key.Key.PublicKey.ShouldBe("ssh-ed25519 AAAAC3Nz nobody@example");
        key.Key.Notes.ShouldContain("id_ed25519");

        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBe(key.EntityId);
        vault.Hosts[0].Authentication.ShouldBe("key");

        import.KeyReport.ShouldHaveSingleItem().ShouldContain("imported");
        import.Status.ShouldContain("1 private key");
    }

    /// <remarks>
    /// <para>
    /// One vault key per file, however many entries named it — the shape a real <c>ssh_config</c> actually
    /// has. Twelve copies of one private key would be twelve things to rotate and eleven to forget, which is
    /// the argument <c>HostSecret.SshKeyId</c> already makes for referencing a key rather than embedding it.
    /// </para>
    /// <para>
    /// The second half is the one that would go unnoticed: importing the same config twice must bind to the
    /// key that is already there rather than storing a second copy of it. Asserted by running the whole
    /// import again over a keychain that now holds one.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TwoHostsNamingOneKeyFile_ShareOneStoredKeyAndARepeatAddsNone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = KeyedConfigDirectory("""
            Host web-01
              HostName web-01.internal
              User deploy
              IdentityFile ~KEY~

            Host web-02
              HostName web-02.internal
              User deploy
              IdentityFile ~KEY~
            """);

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.ImportsKeys = true;

        await import.ImportCommand.ExecuteAsync(null);

        var key = vault.Keys.ShouldHaveSingleItem("one file is one key");

        vault.Hosts.Count.ShouldBe(2);
        vault.Hosts.ShouldAllBe(row => row.Host.SshKeyId == key.EntityId);
        import.Status.ShouldContain("1 private key");

        // Again, over a keychain that already holds it. The hosts duplicate — a second bookmark for one
        // machine is allowed and takes a click — and the key must not.
        foreach (var row in import.Rows)
        {
            row.IsSelected = true;
        }

        await import.ImportCommand.ExecuteAsync(null);

        vault.Keys.ShouldHaveSingleItem("the material was already here, so it was bound to rather than stored");
        import.Status.ShouldNotContain("private key", Case.Insensitive);
    }

    /// <remarks>
    /// A passphrase is the one thing about a key file that is not in the key file, so an encrypted one comes
    /// in without it and would fail at connect time with SSH.NET's own message. The import says so instead —
    /// naming the file, because a config with a dozen keys has a dozen candidates for which one it was.
    /// </remarks>
    [Fact]
    public async Task AnEncryptedKeyIsImportedAndSaidToBeEncrypted()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = KeyedConfigDirectory();

        await File.WriteAllTextAsync(
            Path.Combine(sshDirectory, "id_ed25519"),
            "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-128-CBC,00\n\nQUJD\n"
                + "-----END RSA PRIVATE KEY-----\n",
            Token);

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.ImportsKeys = true;

        await import.ImportCommand.ExecuteAsync(null);

        vault.Keys.ShouldHaveSingleItem().Key.Passphrase.ShouldBeNull("nothing on disk says what it is");

        var reported = import.KeyReport.ShouldHaveSingleItem();

        reported.ShouldContain("passphrase");
        reported.ShouldContain("id_ed25519");
    }

    /// <remarks>
    /// A config carried from another machine names keys that are not on this one, and that is the ordinary
    /// case rather than an error. The host still imports — unbound, exactly as it would have without the
    /// tick — and the report says which file was missing, because otherwise the only symptom is a host that
    /// asks for a password on a screen that said it would not.
    /// </remarks>
    [Fact]
    public async Task AKeyFileThatIsNotThere_LeavesTheHostImportedAndUnbound()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = KeyedConfigDirectory();

        File.Delete(Path.Combine(sshDirectory, "id_ed25519"));

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.ImportsKeys = true;

        await import.ImportCommand.ExecuteAsync(null);

        vault.Keys.ShouldBeEmpty();
        vault.Hosts.ShouldHaveSingleItem().Host.SshKeyId.ShouldBeNull();

        import.KeyReport.ShouldHaveSingleItem().ShouldContain("no such file");
    }

    /// <summary>
    /// A <c>~/.ssh</c> holding a config that names a key, and the key beside it.
    /// </summary>
    /// <remarks>
    /// The key path is written into the config as an absolute one, which is what
    /// <c>SshConfigResolver.ExpandHome</c> produces for a real <c>~/.ssh/id_ed25519</c> — the tests cannot
    /// use a tilde, since that would resolve against the profile of whoever is running them.
    /// </remarks>
    private string KeyedConfigDirectory(string? config = null)
    {
        var sshDirectory = Path.Combine(directory, $"ssh-{Guid.CreateVersion7():N}");

        Directory.CreateDirectory(sshDirectory);

        var keyPath = Path.Combine(sshDirectory, "id_ed25519");

        File.WriteAllText(keyPath, PrivateKey("QUJDRA"));
        File.WriteAllText(keyPath + ".pub", "ssh-ed25519 AAAAC3Nz nobody@example\n");

        var text = config ?? """
            Host web-01
              HostName web-01.internal
              User deploy
              IdentityFile ~KEY~
            """;

        File.WriteAllText(
            Path.Combine(sshDirectory, "config"),
            text.Replace("~KEY~", keyPath, StringComparison.Ordinal));

        return sshDirectory;
    }

    [Fact]
    public async Task ImportingWithNoConfigFile_SaysSoRatherThanFailing()
    {
        await UnlockedAsync();

        var import = new ImportViewModel(
            shell.Vault!,
            new SshConfigLocator(Path.Combine(directory, "nothing-here")));

        await import.ScanCommand.ExecuteAsync(null);

        import.Rows.ShouldBeEmpty();
        import.Status.ShouldContain("no", Case.Insensitive);
    }

    // ---- v5c-3: the WHAT THIS MEANS chip, tick-all, and the footer's own facts ----

    /// <remarks>
    /// The three real states a row can be in, and nothing else: a skipped <c>Host</c> pattern never becomes a
    /// row at all (see <c>SshConfigImport.SkippedPatterns</c>), so there is no fourth, invented "skipped" chip
    /// to test for. A warned row wins over "already here" — see <c>ImportRowViewModel.Meaning</c>.
    /// </remarks>
    [Fact]
    public async Task TheImportersMeaningChipsMapTheRealRowStatesHonestly()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        var sshDirectory = Path.Combine(directory, $"ssh-meaning-{Guid.CreateVersion7():N}");
        Directory.CreateDirectory(sshDirectory);

        await File.WriteAllTextAsync(
            Path.Combine(sshDirectory, "config"),
            """
            Host already-here
              HostName db.internal
              User deploy

            Host bastion
              HostName bastion.internal
              User ops
              ProxyCommand nc %h %p

            Host fresh
              HostName fresh.internal
              User deploy
            """,
            Token);

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.Rows.Count.ShouldBe(3);

        var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
        known.IsMeaningExisting.ShouldBeTrue();
        known.IsMeaningNew.ShouldBeFalse();
        known.IsMeaningWarned.ShouldBeFalse();
        known.Meaning.ShouldBe("already here");

        var warned = import.Rows.Single(row => string.Equals(row.Alias, "bastion", StringComparison.Ordinal));
        warned.IsMeaningWarned.ShouldBeTrue();
        warned.IsMeaningNew.ShouldBeFalse();
        warned.IsMeaningExisting.ShouldBeFalse();
        // The warned chip carries the row's own real reason.
        warned.Meaning.ShouldContain("ProxyCommand");

        var fresh = import.Rows.Single(row => string.Equals(row.Alias, "fresh", StringComparison.Ordinal));
        fresh.IsMeaningNew.ShouldBeTrue();
        fresh.IsMeaningExisting.ShouldBeFalse();
        fresh.IsMeaningWarned.ShouldBeFalse();
        fresh.Meaning.ShouldBe("new host");
    }

    /// <remarks>The header's own tick-all box, over <see cref="ImportViewModel.ToggleAllCommand"/>.</remarks>
    [Fact]
    public async Task TickingAllTogglesEveryRowAndTheHeaderTickReflectsIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = Path.Combine(directory, $"ssh-tickall-{Guid.CreateVersion7():N}");
        Directory.CreateDirectory(sshDirectory);

        await File.WriteAllTextAsync(
            Path.Combine(sshDirectory, "config"),
            """
            Host a
              HostName a.internal

            Host b
              HostName b.internal
            """,
            Token);

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
        await import.ScanCommand.ExecuteAsync(null);

        import.AllTicked.ShouldBeTrue("both are new hosts, which start ticked");

        import.Rows[0].IsSelected = false;
        import.NoteSelectionChanged();

        import.AllTicked.ShouldBeFalse();

        import.ToggleAllCommand.Execute(null);

        import.AllTicked.ShouldBeTrue("fewer than all ticked toggles everything on");
        import.Rows.ShouldAllBe(row => row.IsSelected);

        import.ToggleAllCommand.Execute(null);

        import.AllTicked.ShouldBeFalse();
        import.Rows.ShouldAllBe(row => !row.IsSelected);
    }

    /// <remarks>
    /// The key-material opt-in card's own always-visible sentence: a real count of hosts naming a key file,
    /// the real directory, and the same "nothing is read until Import is pressed" claim verified against
    /// <see cref="SshConfigLocator.ReadIdentity"/> only ever being called from <c>ImportAsync</c>.
    /// </remarks>
    [Fact]
    public async Task TheKeyMaterialCardsIntroSentence_NamesTheRealCountAndDirectory()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = KeyedConfigDirectory();
        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));

        await import.ScanCommand.ExecuteAsync(null);

        import.KeyMaterialIntro.ShouldContain("1 host names");
        import.KeyMaterialIntro.ShouldContain(sshDirectory);
        import.KeyMaterialIntro.ShouldContain(
            "nothing is read until Import is pressed", Case.Insensitive);
    }

    [Fact]
    public async Task TheFooterSummary_NamesTheRealSelectionCountAndVault()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var sshDirectory = Path.Combine(directory, $"ssh-summary-{Guid.CreateVersion7():N}");
        Directory.CreateDirectory(sshDirectory);

        await File.WriteAllTextAsync(
            Path.Combine(sshDirectory, "config"), "Host a\n  HostName a.internal\n", Token);

        var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
        await import.ScanCommand.ExecuteAsync(null);

        import.SelectionSummary.ShouldBe($"1 of 1 entry selected · saving to {vault.VaultName}");
    }

    // ---- Filtering the host sidebar ----

    /// <remarks>
    /// The filter's own list is a projection over <see cref="VaultViewModel.Hosts"/>, not the bound source
    /// of the sidebar's selection — but <c>HostSidebar</c>'s <c>ListBox</c> two-way binds
    /// <c>SelectedItem</c> to <see cref="VaultViewModel.SelectedHost"/> against exactly that projection, so
    /// a naive rebuild that cleared the list before refilling it would have the list null the selection out
    /// from under the user on every keystroke, even when the filter still matches the selected host.
    /// </remarks>
    [Fact]
    public async Task FilteringTheHostListPreservesTheSelectionWhenItStillMatches()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-web-01");
        await AddHostAsync(vault, "prod-web-02");

        vault.SelectedHost = vault.Hosts.Single(
            host => string.Equals(host.Label, "prod-web-01", StringComparison.Ordinal));

        vault.HostFilter = "prod";

        vault.VisibleHosts.Count.ShouldBe(2, "both hosts match the filter");
        vault.SelectedHost.ShouldNotBeNull();
        vault.SelectedHost!.Label.ShouldBe("prod-web-01", "a filter that still matches must not clear it");

        vault.HostFilter = "web-02";

        vault.VisibleHosts.ShouldHaveSingleItem();
        vault.SelectedHost.ShouldBeNull("the selected host no longer matches, so there is nothing to keep");

        vault.HostFilter = string.Empty;

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

    /// <remarks>
    /// The same hazard as the filter, reached through the other caller of the rebuild: a background sync
    /// pass reloads the host list every minute, and <c>ReloadHostsAsync</c> deliberately restores the
    /// selection before handing off to the sidebar's projection. Losing it there would be exactly the "move
    /// the terminal's target out from under the user" outcome that restoration exists to prevent.
    /// </remarks>
    [Fact]
    public async Task ReloadingTheVaultPreservesTheSidebarsSelection()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");

        vault.SelectedHost = vault.Hosts.Single(
            host => string.Equals(host.Label, "stage-web", StringComparison.Ordinal));

        await vault.LoadAsync(Token);

        vault.SelectedHost.ShouldNotBeNull();
        vault.SelectedHost!.Label.ShouldBe("stage-web");
        vault.VisibleHosts.ShouldContain(row => ReferenceEquals(row, vault.SelectedHost));
    }

    // ---- Groups ----

    /// <remarks>
    /// The property that makes this feature free to ignore. Somebody with eleven machines and no wish to file
    /// them should see the list they have always seen — not a heading telling them their hosts are ungrouped.
    /// </remarks>
    [Fact]
    public async Task AVaultWithNoGroups_DrawsNoHeadings()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");

        vault.HasGroups.ShouldBeFalse();
        vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);
        vault.SidebarRows.Count.ShouldBe(vault.VisibleHosts.Count);
    }

    [Fact]
    public async Task FilingAHostIntoAGroup_PutsItUnderThatHeading()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");
        await AddGroupAsync(vault, "production");

        await FileAsync(vault, "prod-db", "production");

        var rows = vault.SidebarRows.ToArray();

        // One group, so: its heading, its one host, then the ungrouped heading and the other host.
        rows[0].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("production");
        rows[1].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("prod-db");
        rows[2].ShouldBeOfType<SidebarGroupHeader>().Label.ShouldBe("UNGROUPED");
        rows[3].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("stage-web");
    }

    /// <remarks>
    /// v5c-2: the settings Groups page's "No group" footer row. Counts a host whose group has never been set
    /// and one whose group id dangles (deleted from under it) the same way — both are "ungrouped" to a person
    /// looking at the list, per the reading <c>FlattenIntoSections</c> already gives the sidebar's own
    /// heading, and <c>UngroupedHostCount</c> has to agree with it rather than invent a second definition.
    /// </remarks>
    [Fact]
    public async Task UngroupedHostCount_CountsHostsWithNoGroupAndHostsWhoseGroupHasGone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");
        await AddHostAsync(vault, "bastion");
        await AddGroupAsync(vault, "production");

        vault.UngroupedHostCount.ShouldBe(3, "no host has been filed under the new group yet");

        await FileAsync(vault, "prod-db", "production");

        vault.UngroupedHostCount.ShouldBe(2, "one host now belongs to a real group");

        var group = vault.Groups.Single();
        vault.DeleteGroupCommand.Execute(group);
        vault.PendingDeletion.ShouldNotBeNull();
        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.UngroupedHostCount.ShouldBe(
            3, "a host whose group was deleted falls back to ungrouped rather than vanishing from the count");
    }

    /// <remarks>
    /// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder
    /// somebody made and can put things in, the second is an absence of search results — and a heading with
    /// nothing under it reads as a group that has lost its contents.
    /// </remarks>
    [Fact]
    public async Task AGroupEmptiedByTheFilter_LosesItsHeading()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await AddGroupAsync(vault, "staging");
        await FileAsync(vault, "prod-db", "production");

        Headings(vault).ShouldBe(["production", "staging"], "an empty group keeps its heading");

        vault.HostFilter = "nothing matches this";

        Headings(vault).ShouldBe(["production", "staging"]);
        vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty();
    }

    [Fact]
    public async Task FoldingAGroupAwayHidesItsHostsAndSurvivesAReload()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());

        vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("the group is folded away");

        // Folded state is held by group id rather than on the row, because a background sync rebuilds every
        // row once a minute and a flag on one would be forgotten the first time it did.
        await vault.LoadAsync(Token);

        vault.SidebarRows.OfType<HostRowViewModel>().ShouldBeEmpty("and a reload does not unfold it");
    }

    /// <remarks>
    /// The heading is a row in the same <c>ListBox</c> as the hosts, so the control will select it. Nothing
    /// else in the application acts on a heading — CONNECT, EDIT and DELETE all read the host selection — so
    /// clicking one has to leave that selection exactly where it was.
    /// </remarks>
    [Fact]
    public async Task SelectingAHeading_LeavesTheHostSelectionAlone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        var host = vault.Hosts.Single();
        vault.SelectedHost = host;

        vault.SelectedSidebarRow = vault.SidebarRows.OfType<SidebarGroupHeader>().First();

        vault.SelectedHost.ShouldBeSameAs(host);
        vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
    }

    /// <remarks>
    /// <para>
    /// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
    /// <see cref="FileAsync"/> — which is the one thing every head can still do. What survives to measure is
    /// <see cref="VaultViewModel.Matches"/> and <see cref="VaultViewModel.RebuildVisibleHosts"/>'s own
    /// "one level of the tree" filtering, fed by <see cref="VaultViewModel.GroupFilter"/> directly now that
    /// nothing sets it through a command — see that property's own remarks.
    /// </para>
    /// <para>
    /// <b>The card goes into the group and off the level it was filed from</b>, which is the whole of what
    /// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
    /// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");

        var group = vault.Groups.Single().EntityId;

        await FileAsync(vault, "prod-db", "production");

        vault.Hosts.Single().Host.GroupId.ShouldBe(group);
        vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");

        // Under the group's own heading now, which is what the phone's list draws — that list is the whole
        // tree flattened, so the host is still in it.
        vault.SidebarRows.OfType<SidebarGroupHeader>()
            .Single(header => header.GroupId == group)
            .Count.ShouldBe(1);

        // The name is on the card, which is what the desktop's grid draws instead of that heading, and what
        // says which group a searched-up card came out of.
        vault.Hosts.Single().GroupLabel.ShouldBe("production");
        vault.Hosts.Single().HasGroup.ShouldBeTrue();

        // Opening the group is where it went, and the way to it.
        vault.GroupFilter = vault.Groups.Single();

        vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");

        // And back out again.
        vault.GroupFilter = null;

        vault.SelectedHost = vault.Hosts.Single();
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => choice.EntityId is null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.Single().Host.GroupId.ShouldBeNull();
        vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
        vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
    }

    /// <summary>
    /// The grid holds one level of the tree, the way a directory pane holds one directory.
    /// </summary>
    /// <remarks>
    /// A host filed under a group is inside that group and is not also on the screen the group's card sits
    /// on. While it was both, opening a group could only ever take hosts away — the level above already had
    /// all of them — and the cards were headings rather than places. The phone's list is the deliberate
    /// exception and is asserted here beside it: it draws the whole tree flat under headings, because it has
    /// no cards to open and nowhere to open one into.
    /// </remarks>
    [Fact]
    public async Task AHostFiledUnderAGroup_IsDrawnInsideItAndNotAtTheLevelAbove()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.VisibleHosts.Select(row => row.Label)
            .ShouldBe(["stage-web"], "the outermost level is what nothing has been filed into");

        vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
            .ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");

        vault.GroupFilter = vault.Groups.Single();

        vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);

        vault.GroupFilter = null;

        vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
    }

    /// <remarks>
    /// The one thing on the screen that crosses a group boundary, and it has to be one. A search that looked
    /// only at the level it was typed on would answer "no host matches that" about a machine this keychain
    /// has got — and finding a machine without first remembering where it was filed is most of what the box
    /// is for. Typed at the outermost level it reaches everything; typed inside a group it reaches that
    /// group and what is under it, which is the same rule read from where you are standing.
    /// </remarks>
    [Fact]
    public async Task TheFindBox_SearchesInsideTheGroupsRatherThanOnlyTheLevelOnScreen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");
        await AddGroupAsync(vault, "estate");
        await AddGroupAsync(vault, "production");
        await SetGroupParentAsync(vault, "production", "estate");
        await FileAsync(vault, "prod-db", "production");

        vault.VisibleHosts.ShouldNotContain(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));

        vault.HostFilter = "prod-db";

        vault.VisibleHosts.ShouldHaveSingleItem().Label
            .ShouldBe("prod-db", "two levels down, and the box reaches it");

        // And inside a group it is that group's subtree: estate holds production, which holds the host.
        vault.GroupFilter =
            vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));

        vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");

        vault.HostFilter = "stage";

        vault.VisibleHosts.ShouldBeEmpty("stage-web is outside estate, so this search does not reach it");
        vault.NoVisibleHostsMessage.ShouldContain("ALL HOSTS", Case.Sensitive, "and it says how to widen it");
    }

    /// <remarks>
    /// A grid with no cards in it has to say why, and "they are all filed away" is a different sentence from
    /// "there are none" and from "nothing matches what you typed". It is the answer the level-at-a-time grid
    /// made reachable: before it, a keychain with hosts in it always drew some.
    /// </remarks>
    [Fact]
    public async Task AKeychainWhoseHostsAreAllFiled_SaysSoRatherThanLookingEmpty()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.HasVisibleHosts.ShouldBeFalse();
        vault.NoVisibleHostsMessage.ShouldContain("filed under a group");
        vault.NoVisibleHostsMessage.ShouldNotContain(
            "No hosts yet", Case.Sensitive, "telling somebody with hosts to add their first one answers nothing");
    }

    /// <remarks>
    /// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
    /// inside a group and filed under none would vanish from the screen it was created on, which is the
    /// papercut that comes free with a grid holding one level — so the editor opens on the group
    /// <see cref="VaultViewModel.GroupTarget"/> names, and the picker shows it before anything is saved.
    /// <c>GroupFilter</c> is written directly rather than through the deleted <c>OpenGroupCommand</c> — see
    /// that property's own remarks.
    /// </remarks>
    [Fact]
    public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddGroupAsync(vault, "production");

        vault.GroupFilter = vault.Groups.Single();

        vault.NewHostCommand.Execute(null);

        vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("production");

        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.VisibleHosts.ShouldHaveSingleItem().Label
            .ShouldBe("prod-db", "so it is on the screen it was made on");

        vault.NewGroupCommand.Execute(null);

        vault.GroupEditorSelectedParent.ShouldNotBeNull().Label
            .ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
    }

    /// <remarks>
    /// <para>
    /// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
    /// only the first: the reference was left dangling and the list resolved it to nothing, which looked
    /// identical and cost no writes. The tick is what changed that — a deletion that can take the hosts with
    /// it has to be a deletion that knows which hosts it means, and once it knows, leaving them holding the
    /// id of something that has gone is a state kept for no reason.
    /// </para>
    /// <para>
    /// The tick is deliberately not touched here, which is the point of the assertions: the default answer
    /// is the one that keeps the machines.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task DeletingAGroup_UnfilesItsHostsRatherThanLeavingThemNamingIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.DeleteGroupCommand.Execute(vault.Groups.Single());

        var question = vault.PendingDeletion.ShouldNotBeNull();

        question.Usage
            .ShouldContain("1 host", Case.Sensitive, "the count is what makes the question worth reading");

        question.HasChoice.ShouldBeTrue("a group with a host under it has a second question");
        vault.DeletionTakesTheHostsToo.ShouldBeFalse("the safe answer is the one nobody has to choose");

        // The pass that follows every write on this screen reports what it moved and supersedes the
        // confirmation, for a deletion as much as for a save — so it is made to fail, and what the sentence
        // says is asserted in the state where somebody actually reads it.
        server.SyncFailure = new HttpRequestException("The server is having a bad day.");

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Groups.ShouldBeEmpty();
        vault.HasGroups.ShouldBeFalse();

        vault.Hosts.Single().Host.GroupId.ShouldBeNull(vault.Status);
        vault.Hosts.Single().GroupLabel.ShouldBeEmpty();
        vault.SidebarRows.ShouldAllBe(row => row is HostRowViewModel);

        vault.Status.ShouldContain("UNGROUPED", Case.Sensitive);
    }

    /// <remarks>
    /// The other answer, and the reason the question is asked at all: a group is sometimes a heading being
    /// tidied away and sometimes a project that has been decommissioned, and nothing in the view model can
    /// tell which of the two it is looking at.
    /// </remarks>
    [Fact]
    public async Task DeletingAGroupWithTheTickSet_TakesItsHostsWithIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "prod-web");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.DeleteGroupCommand.Execute(vault.Groups.Single());

        vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
        vault.DeletionTakesTheHostsToo = true;

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Groups.ShouldBeEmpty();

        // Only the machine that was filed under it. A deletion aimed at a heading must not reach the hosts
        // that were never on it.
        vault.Hosts.Select(row => row.Label).ShouldBe(["prod-web"], vault.Status);
    }

    /// <remarks>
    /// The answer is not carried from one question to the next. A tick left standing would delete the next
    /// group's machines on the strength of a decision about the last one's, and there is no undo on either
    /// side of that.
    /// </remarks>
    [Fact]
    public async Task AskingAboutASecondGroup_StartsFromKeepingItsHosts()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await AddGroupAsync(vault, "staging");
        await FileAsync(vault, "prod-db", "production");

        vault.DeleteGroupCommand.Execute(vault.Groups.Single(
            row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
        vault.DeletionTakesTheHostsToo = true;
        vault.CancelDeleteCommand.Execute(null);

        vault.DeleteGroupCommand.Execute(vault.Groups.Single(
            row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));

        vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
    }

    /// <remarks>
    /// Unfiling rewrites every host under the heading, so it is refused with a host editor open for the
    /// reason a drop onto a group card is: rewriting the saved host under a half-typed edit of it would be a
    /// save nobody asked for, and one they could then not cancel. Deleting a single host is not refused,
    /// because that one writes nothing to a form anybody is looking at.
    /// </remarks>
    [Fact]
    public async Task DeletingAGroupWhileTheHostEditorIsOpen_IsRefused()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = vault.Hosts.Single();
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorLabel = "half-typed";

        vault.DeleteGroupCommand.Execute(vault.Groups.Single());

        vault.PendingDeletion.ShouldBeNull("the question was never put");
        vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
        vault.Status.ShouldContain("editing");
    }

    /// <remarks>
    /// <para>
    /// The picker keeps a placeholder entry for a group the vault does not have, exactly as the
    /// authentication picker does for a deleted key. Without it the picker would open on "No group" and
    /// somebody editing the host's port would unfile it by saving.
    /// </para>
    /// <para>
    /// The dangling id is imported rather than produced by deleting the group, and that is a consequence of
    /// the change above rather than a contrivance: a group deleted <em>here</em> now unfiles its hosts on the
    /// way out, so the only way a host still names one is that the group went on another machine and this
    /// client has yet to be told — which is exactly what a host arriving with an id nothing resolves is.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task EditingAHostWhoseGroupIsGone_DoesNotUnfileItBySaving()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        var groupId = Guid.CreateVersion7();

        await vault.ImportHostsAsync(
            [
                new ImportedHostRequest(
                    new HostSecret { Label = "prod-db", Hostname = "db.internal", GroupId = groupId },
                    Key: null,
                    KeyPath: null),
            ],
            Token);

        vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");

        vault.SelectedHost = vault.Hosts.Single();
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorSelectedGroup.ShouldNotBeNull().EntityId.ShouldBe(groupId);

        vault.EditorPort = 2222;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.Hosts.Single().Host.GroupId.ShouldBe(groupId, "an unrelated edit must not unfile the host");
    }

    [Fact]
    public async Task RenamingAGroup_RenamesItsHeading()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.EditGroupCommand.Execute(vault.Groups.Single());

        vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");

        vault.GroupEditorLabel = "live";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        Headings(vault).ShouldBe(["live"]);
        vault.EditingGroupId.ShouldBeNull("the box goes back to creating once the rename is saved");
    }

    // ---- What a host takes from its group ----
    //
    // The tests below dial. That is the point of them: a resolved value that never reaches
    // SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting
    // to the wrong port, or being asked for a password it does not need, with nothing on screen admitting
    // it. AddHostAsync gives its host a username, so each of these clears what it is about first.

    [Fact]
    public async Task AHostThatStatesNoPort_DialsItsGroups()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", port: 2222);
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = Host(vault, "prod-db");

        // The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
        // factory is ever reached, and this would pass no matter what port was resolved.
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(2222);
    }

    [Fact]
    public async Task AHostThatPinsItsOwnPort_KeepsItUnderAGroupThatSaysOtherwise()
    {
        // The other direction, and the one that decides whether inheritance is safe to turn on: a host that
        // was explicit must not start dialling somewhere else because somebody edited a group.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", port: 2222);
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorPort = 22;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.SelectedHost = Host(vault, "prod-db");

        // The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
        // factory is ever reached, and this would pass no matter what port was resolved.
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
    }

    [Fact]
    public async Task AHostThatStatesNoUsername_LogsInAsItsGroups()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", username: "deploy");
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorUsername = string.Empty;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.SelectedHost = Host(vault, "prod-db");

        // The connect path opens a terminal, so it needs one attached — otherwise it refuses before the SSH
        // factory is ever reached, and this would pass no matter what port was resolved.
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldHaveSingleItem().Username.ShouldBe("deploy");
    }

    [Fact]
    public async Task AGroupsDefaultPort_ShowsAsThePlaceholderInTheHostEditor()
    {
        // What makes an empty box honest. Without this the form asks the user to leave a field blank and
        // says nothing about what blank will get them.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorPortPlaceholder.ShouldBe("22", "an ungrouped host falls to the end of the chain");

        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));

        vault.EditorPortPlaceholder.ShouldBe(
            "2222", "the placeholder follows the group picker, or it describes the wrong group");

        vault.EditorUsernamePlaceholder.ShouldBe("deploy");
    }

    [Fact]
    public async Task AHostUnderAGroupThatBindsAKey_DoesNotAskForAPassword()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", key: "deploy");
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = Host(vault, "prod-db");

        vault.SelectedHostAsksForAPassword.ShouldBeFalse();
        vault.SelectedHostAuthenticationNote.ShouldContain("production");
        Host(vault, "prod-db").Authentication.ShouldBe("key");
    }

    [Fact]
    public async Task AHostPinnedToATypedPassword_KeepsAskingUnderAGroupThatBindsAKey()
    {
        // The failure worth ruling out above every other one here. A host deliberately set back to a typed
        // password must not start authenticating with the fleet's key because somebody set a group default.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", key: "deploy");
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorSelectedAuthentication.ShouldBe(
            AuthenticationChoice.Inherited, "a host that binds nothing under a group is inheriting, not typing");

        vault.EditorSelectedAuthentication = AuthenticationChoice.Typed;
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.SelectedHost = Host(vault, "prod-db");

        vault.SelectedHostAsksForAPassword.ShouldBeTrue();
        Host(vault, "prod-db").Host.AsksForPassword.ShouldBe(true);
    }

    [Fact]
    public async Task AKeyBoundOnlyByAGroup_WarnsAboutTheHostsBeneathItBeforeItIsDeleted()
    {
        // Counting each host's own ids would warn about nobody here, and then refuse every host beneath the
        // group at connect time. The warning is the only thing standing between the two.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", key: "deploy");
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedKey = vault.Keys.Single();
        vault.DeleteKeyCommand.Execute(null);

        vault.PendingDeletion.ShouldNotBeNull().Usage.ShouldContain("prod-db");
    }

    [Fact]
    public async Task AGroupsParentPicker_LeavesOutItselfAndEverythingBeneathIt()
    {
        // A cycle cannot be made here. It can still arrive from two offline re-parents, which is why the
        // walk carries a visited set — this only keeps a user from doing it to themselves.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddGroupAsync(vault, "estate");
        await AddGroupAsync(vault, "production");
        await SetGroupParentAsync(vault, "production", "estate");

        vault.EditGroupCommand.Execute(vault.Groups.Single(
            row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));

        vault.GroupEditorParentChoices
            .Select(choice => choice.Label)
            .ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it");
    }

    // ---- What the phone's + drives ----
    //
    // The phone's own pixels are not measurable here and cannot be: the layout suite is net10.0 and
    // DodoSSH.Client.Android is net10.0-android, so its views are unreachable by construction, and Avalonia's
    // application is a process global so a second head cannot share this process either. What IS shared is
    // every property and command the sheet and its two editors bind to, which is all of the behaviour — the
    // markup only decides where it is drawn. So the flow is tested here and the rectangles go to
    // docs/manual-checks.md, which is where this project already sends what it cannot assert.

    [Fact]
    public async Task TheAddSheet_OffersTwoThingsAndOpensNeitherUntilOneIsChosen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.OpenAddSheetCommand.Execute(null);

        vault.IsAddSheetOpen.ShouldBeTrue();
        vault.AnEditorIsOpen.ShouldBeTrue("the + hides while anything is over the list");
        vault.IsEditing.ShouldBeFalse();
        vault.IsEditingGroup.ShouldBeFalse();

        vault.NewHostCommand.Execute(null);

        vault.IsAddSheetOpen.ShouldBeFalse("choosing lowers the sheet rather than stacking on it");
        vault.IsEditing.ShouldBeTrue();
    }

    [Fact]
    public async Task TheAddSheet_ClosesWithoutOpeningAnything()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.OpenAddSheetCommand.Execute(null);
        vault.CloseAddSheetCommand.Execute(null);

        vault.AnEditorIsOpen.ShouldBeFalse();
        vault.IsEditing.ShouldBeFalse();
        vault.IsEditingGroup.ShouldBeFalse();
    }

    [Fact]
    public async Task ANewGroupFromTheSheet_StartsEmptyRatherThanOnTheLastOneEdited()
    {
        // The failure this rules out is quiet: a group editor left holding the previous group's default key
        // would lend it to the next group somebody created without anybody choosing it.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy", key: "deploy");

        vault.OpenAddSheetCommand.Execute(null);
        vault.NewGroupCommand.Execute(null);

        vault.IsEditingGroup.ShouldBeTrue();
        vault.IsAddSheetOpen.ShouldBeFalse();
        vault.EditingGroupId.ShouldBeNull("this is an add, not a rename");
        vault.GroupEditorLabel.ShouldBeEmpty();
        vault.GroupEditorDefaultPort.ShouldBeNull();
        vault.GroupEditorDefaultUsername.ShouldBeEmpty();
        vault.GroupEditorSelectedAuthentication.ShouldBe(AuthenticationChoice.NoDefault);
        vault.GroupEditorSelectedParent.ShouldBe(GroupChoice.None);
    }

    [Fact]
    public async Task AGroupAddedFromTheSheet_CarriesTheDefaultsTypedIntoIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = "production";
        vault.GroupEditorDefaultPort = 2222;
        vault.GroupEditorDefaultUsername = "deploy";

        await vault.SaveGroupCommand.ExecuteAsync(null);

        vault.IsEditingGroup.ShouldBeFalse("saving lowers the card");

        var group = vault.Groups.Single();
        group.Group.Label.ShouldBe("production");
        group.Group.DefaultPort.ShouldBe(2222);
        group.Group.DefaultUsername.ShouldBe("deploy");
    }

    [Fact]
    public async Task TheSheet_RefusesToOpenOverAnEditorRatherThanStackingOnIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        vault.NewHostCommand.Execute(null);
        vault.OpenAddSheetCommand.Execute(null);

        vault.IsAddSheetOpen.ShouldBeFalse();
        vault.Status.ShouldContain("Finish or cancel", Case.Insensitive);
    }

    [Fact]
    public async Task ANewHostFromTheSheet_LeavesItsPortToWhicheverGroupItIsFiledUnder()
    {
        // The phone's add flow end to end, and the reason the port box opens empty: a host created under a
        // group that says 2222 wants 2222 without anybody typing it, and stays wanting whatever the group
        // says afterwards.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");

        vault.OpenAddSheetCommand.Execute(null);
        vault.NewHostCommand.Execute(null);

        vault.EditorPort.ShouldBeNull("an empty box is what leaves the port to the group");

        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));

        vault.EditorPortPlaceholder.ShouldBe("2222", "the form says what leaving it blank will get you");
        vault.EditorUsernamePlaceholder.ShouldBe("deploy");

        await vault.SaveHostCommand.ExecuteAsync(null);

        var host = Host(vault, "prod-db");
        host.Host.Port.ShouldBeNull("nothing was typed, so nothing was pinned");
        host.Resolved.Port.Value.ShouldBe(2222);
        host.Resolved.Username.Value.ShouldBe("deploy");
    }

    [Fact]
    public async Task AGroupsParentPicker_LetsAGroupBeFiledUnderAnother()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddGroupAsync(vault, "estate");
        await AddGroupAsync(vault, "production");
        await SetGroupParentAsync(vault, "production", "estate");

        vault.Groups
            .Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal))
            .Group.ParentId
            .ShouldBe(vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal))
                .EntityId);
    }

    [Fact]
    public async Task AHostUnderANestedGroup_TakesTheNearestAnswerAndKeepsWalkingForTheRest()
    {
        // Two levels, and each field resolved on its own: a group that answers one question does not stop
        // the walk for the others.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "estate");
        await AddGroupAsync(vault, "production");
        await SetGroupDefaultsAsync(vault, "estate", username: "root");
        await SetGroupDefaultsAsync(vault, "production", port: 2222);
        await SetGroupParentAsync(vault, "production", "estate");
        await FileAsync(vault, "prod-db", "production");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorUsername = string.Empty;
        await vault.SaveHostCommand.ExecuteAsync(null);

        var host = Host(vault, "prod-db");
        host.Resolved.Port.Value.ShouldBe(2222, "the nearer group answers the port");
        host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not");
    }

    /// <remarks>
    /// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> Choosing a host used to
    /// raise a connect card — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list —
    /// which meant a tap on a machine's name put five controls in the way of the one thing it obviously
    /// means. A tap connects now, and this pins that it raises nothing on the way past.
    /// </remarks>
    [Fact]
    public async Task ATapOnAHost_ConnectsAndRaisesNothing()
    {
        var vault = await ReadyToConnectAsync();

        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.SelectedHost = null;

        await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);

        vault.Status.ShouldContain("Connected", Case.Insensitive);
        vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to");
        vault.IsAskingForConnectPassword.ShouldBeFalse("and nothing was raised over the list to do it");
        vault.IsChoosingHosts.ShouldBeFalse("a tap is not a way into selection mode");
    }

    /// <remarks>
    /// The one tap that cannot finish, and the whole of what is left of the connect card. A host that
    /// authenticates with a typed password has nowhere to be given one from a list, so the tap raises the
    /// password sheet and says so. What it must never do is connect with no password, or leave somebody
    /// tapping a row that silently does nothing.
    /// </remarks>
    [Fact]
    public async Task ATapOnAHostThatWantsAPassword_RaisesTheSheetInsteadOfConnecting()
    {
        var vault = await ReadyToConnectAsync();

        vault.SelectedHost = null;

        await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);

        vault.IsAskingForConnectPassword.ShouldBeTrue("there is nowhere else to type it");
        vault.SelectedHostAsksForAPassword.ShouldBeTrue();
        vault.Status.ShouldContain("password");
        ssh.Requests.ShouldBeEmpty("nothing was dialled with no password");

        // The second tap, with the box filled in, is the one that goes through — otherwise the sheet would
        // be answering the instruction it just gave with the same instruction again.
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ConnectPassword = "typed-in";

        await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);

        ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
        vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded");
    }

    /// <remarks>
    /// Dismissing takes the typed password with it, which is the same bargain the files screen's own picker
    /// makes: a secret left in the box would be somebody else's password sitting in the field the next tap
    /// reads — and, worse, it would satisfy the emptiness check that decides whether to raise the sheet at
    /// all, so the next tap would dial with it.
    /// </remarks>
    [Fact]
    public async Task DismissingThePasswordSheet_EmptiesTheBox()
    {
        var vault = await ReadyToConnectAsync();

        await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);

        vault.ConnectPassword = "half-typed";
        vault.RemembersConnectPassword = true;

        vault.CancelConnectPasswordCommand.Execute(null);

        vault.IsAskingForConnectPassword.ShouldBeFalse();
        vault.ConnectPassword.ShouldBeEmpty();
        vault.RemembersConnectPassword.ShouldBeFalse("and the tick beside it is not carried either");
    }

    // ---- ◆ Choosing hosts, and the seven things the action bar does to them ----
    //
    // The connect card is gone and a long press chooses instead. What these pin is the shape of that: the
    // set survives the things that used to empty it, the entries that are about one machine are offered only
    // for one, and every run over the set says what it left alone.

    /// <remarks>
    /// The two gestures, from the side the phone drives them. A long press adds rather than toggling — a
    /// second one on a machine somebody is holding down on must not take the tick off — and a tap toggles
    /// once the mode is up. Emptying the set leaves the mode, which is the other way out of it.
    /// </remarks>
    [Fact]
    public async Task ALongPressChoosesAHost_AndTapsTickAndUntickFromThereOn()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "staging");

        vault.IsChoosingHosts.ShouldBeFalse();

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));

        vault.IsChoosingHosts.ShouldBeTrue();
        vault.ChosenHostCount.ShouldBe(1);
        vault.HasOneChosenHost.ShouldBeTrue();
        Host(vault, "prod-db").IsChosen.ShouldBeTrue("the tick is drawn on the row");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.ChosenHostCount.ShouldBe(1, "a second long press on the same row is not an untick");

        vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));

        vault.ChosenHostCount.ShouldBe(2);
        vault.HasOneChosenHost.ShouldBeFalse("neither the pencil nor CONNECT is about two machines");

        vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-db"));

        vault.IsChoosingHosts.ShouldBeFalse("unticking the last one leaves selection mode");
        Host(vault, "prod-db").IsChosen.ShouldBeFalse();
    }

    /// <remarks>
    /// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
    /// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
    /// somebody choosing what to do with eleven machines.
    /// </remarks>
    [Fact]
    public async Task TheChosenHosts_SurviveTheListBeingRebuilt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "staging");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));

        await vault.SyncCommand.ExecuteAsync(null);

        vault.ChosenHostCount.ShouldBe(1, vault.Status);
        Host(vault, "prod-db").IsChosen.ShouldBeTrue("written back onto the row the reload made");
        Host(vault, "staging").IsChosen.ShouldBeFalse();
    }

    /// <remarks>
    /// The cross at the left of the bar, and everything it has to take with it: a picker asking which vault
    /// to move nothing to is not a state worth having.
    /// </remarks>
    [Fact]
    public async Task ClearingTheChoice_TakesTheMenuAndItsPanelsWithIt()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.OpenHostActionSheetCommand.Execute(null);
        vault.DeleteChosenHostsCommand.Execute(null);

        vault.IsHostActionSheetOpen.ShouldBeFalse("choosing an entry lowers the menu");
        vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
        vault.AChosenHostPanelIsOpen.ShouldBeTrue();
        vault.ShowsAddButton.ShouldBeFalse("and the + stands down under a question");

        vault.ClearHostChoiceCommand.Execute(null);

        vault.IsChoosingHosts.ShouldBeFalse();
        vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
        vault.ShowsAddButton.ShouldBeTrue();
    }

    /// <remarks>
    /// The pencil at the right of the bar. It edits the one ticked host and leaves selection mode, because
    /// the editor is a page over the list and a bar counting hosts above a form about one of them would be
    /// two answers to what the screen is about.
    /// </remarks>
    [Fact]
    public async Task ThePencil_OpensTheEditorOnTheOneChosenHostAndLeavesSelectionMode()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "staging");

        vault.ChooseHostCommand.Execute(Host(vault, "staging"));
        vault.EditChosenHostCommand.Execute(null);

        vault.IsEditing.ShouldBeTrue();
        vault.EditorLabel.ShouldBe("staging");
        vault.IsChoosingHosts.ShouldBeFalse();

        vault.CancelEditCommand.Execute(null);

        // Two ticked, and the pencil has nothing to be about — the bar collapses it rather than refusing it,
        // but the command has to agree or a stale binding would open the editor on a guess.
        vault.ChooseHostCommand.Execute(Host(vault, "staging"));
        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));

        vault.EditChosenHostCommand.Execute(null);

        vault.IsEditing.ShouldBeFalse("there is no sensible reading of editing two machines");
    }

    /// <remarks>
    /// Filing is the reason the set is worth having: thirty imported machines under one heading used to be
    /// thirty rounds of open, pick, save. It is the same write dragging a card onto a group makes on the
    /// desktop, run over the whole selection.
    /// </remarks>
    [Fact]
    public async Task ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "prod-web");
        await AddHostAsync(vault, "staging");
        await AddGroupAsync(vault, "production");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);

        vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
            .Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));

        await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);

        var group = vault.Groups.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal));

        Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
        Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
        Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");

        vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
    }

    /// <summary>
    /// A question about six hosts does not survive the set becoming seven.
    /// </summary>
    /// <remarks>
    /// The question names a count and the run that answers it reads the set again, and the panel is drawn
    /// above the list rather than over it — deliberately, so the ticked rows stay in view — which leaves
    /// every one of them still tickable while it is up. One more tick between the question and the answer
    /// used to delete a machine nobody had been asked about. The desktop is where this became easy: a
    /// Ctrl-click or a band is a second's work. See <c>VaultViewModel.deletionAskedAbout</c>.
    /// </remarks>
    [Fact]
    public async Task TickingAnotherHost_DropsTheDeletionQuestionAlreadyOnScreen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "prod-web");
        await AddHostAsync(vault, "staging");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));

        vault.DeleteChosenHostsCommand.Execute(null);

        vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 2 hosts?");

        vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));

        vault.IsConfirmingChosenHostDeletion
            .ShouldBeFalse("the question was about two of them and there are three now");
        vault.IsChoosingHosts.ShouldBeTrue("the set is what was just chosen, so it stays");

        vault.DeleteChosenHostsCommand.Execute(null);

        vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 3 hosts?");

        // And unticking back to the set it was asked about does not bring a stale question back up.
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));

        vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
    }

    /// <remarks>
    /// <para>
    /// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
    /// cards, and <c>FileChosenHostsUnderCommand</c> went with it; <c>ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce</c>
    /// covers what a set files to now that the group picker is the only route. What survives here is the
    /// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
    /// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
    /// refusal at the point it is raised instead — see <see cref="VaultViewModel.RegroupChosenHosts"/> —
    /// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
    /// way a card once was.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "prod-web");
        await AddHostAsync(vault, "staging");
        await AddGroupAsync(vault, "production");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));

        vault.NewHostCommand.Execute(null);

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");

        vault.CancelEditCommand.Execute(null);

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");

        var group = vault.Groups.Single(
            row => string.Equals(row.Label, "production", StringComparison.Ordinal));

        vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
            .Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));

        await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);

        Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
        Host(vault, "prod-web").Host.GroupId.ShouldBe(group.EntityId);
        Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
    }

    /// <remarks>
    /// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
    /// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
    /// seeing a machine filed under nothing. Checked when the picker is asked for and over the whole set —
    /// see <see cref="VaultViewModel.RegroupChosenHosts"/> — rather than once per host mid-write, which is
    /// why no panel opens at all and the status line's sentence has to carry the whole explanation.
    /// </remarks>
    [Fact]
    public async Task RegroupingHostsChosenAcrossTwoKeychains_IsRefusedBeforeThePickerOpens()
    {
        await UnlockedAsync();

        var vaults = shell.Vaults;

        await vaults.LoadAsync(Token);

        vaults.NewVaultCommand.Execute(null);
        vaults.NewVaultName = "Platform secrets";

        await vaults.CreateVaultCommand.ExecuteAsync(null);

        var vault = shell.Vault!;
        var sharedVaultId = vaults.SelectedVault!.VaultId;

        await vault.LoadAsync(Token);

        await AddHostAsync(vault, "prod-db");

        vault.NewHostCommand.Execute(null);
        vault.EditorSelectedVault =
            vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
        vault.EditorLabel = "prod-web";
        vault.EditorHostname = "web.internal";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeFalse("a group belongs to one keychain");
        vault.Status.ShouldStartWith("These hosts are in more than one keychain");
        vault.IsChoosingHosts.ShouldBeTrue("the set was refused, not dissolved");

        // Unticking the visitor is all it takes: the refusal is about the set, not a latch the screen has
        // to be talked out of.
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
    }

    /// <remarks>
    /// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
    /// another vault: the copy stays in the same keychain, so everything it points at is still there.
    /// </remarks>
    [Fact]
    public async Task DuplicatingTheChosenHosts_WritesACopyBesideEachOne()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));

        await vault.DuplicateChosenHostsCommand.ExecuteAsync(null);

        vault.Hosts.Count.ShouldBe(2, vault.Status);

        var copy = vault.Hosts.Single(row => row.Label.EndsWith("copy", StringComparison.Ordinal));

        copy.Label.ShouldBe("prod-db copy");
        copy.Host.Hostname.ShouldBe(Host(vault, "prod-db").Host.Hostname);
        copy.Host.GroupId.ShouldNotBeNull("a duplicate stays on the shelf it was made from");
    }

    /// <remarks>
    /// One question naming a count, rather than one question per host: six copies of "delete prod-db?" is
    /// not a confirmation anybody reads. What is pinned as well is that the question is answerable — the
    /// panel it is drawn in is above the list rather than in place of it.
    /// </remarks>
    [Fact]
    public async Task RemovingTheChosenHosts_AsksOnceAndThenTakesThemAll()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "prod-web");
        await AddHostAsync(vault, "staging");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));

        vault.DeleteChosenHostsCommand.Execute(null);

        vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
        vault.PendingDeletion!.Question.ShouldContain("2 hosts");

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Hosts.ShouldHaveSingleItem(vault.Status).Label.ShouldBe("staging");
        vault.IsChoosingHosts.ShouldBeFalse();
    }

    /// <remarks>
    /// KEEP leaves everything alone, including the ticks: the question was about the selection and declining
    /// it is not a reason to throw the selection away.
    /// </remarks>
    [Fact]
    public async Task KeepingTheChosenHosts_LeavesTheTicksWhereTheyWere()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.DeleteChosenHostsCommand.Execute(null);
        vault.CancelDeleteCommand.Execute(null);

        vault.Hosts.ShouldHaveSingleItem();
        vault.IsChoosingHosts.ShouldBeTrue();
        vault.ChosenHostCount.ShouldBe(1);
    }

    /// <remarks>
    /// With one writable keychain there is nowhere to send anything, and the honest answer is a sentence
    /// rather than an empty picker. It is also what somebody in a team whose only other vault is read-only
    /// sees. The two-vault path is <c>VaultSharingTests</c>' job, which is where a second vault exists.
    /// </remarks>
    [Fact]
    public async Task MovingTheChosenHostsWithNowhereToPutThem_SaysSoRatherThanOpeningAnEmptyPicker()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.MoveChosenHostsToVaultCommand.Execute(null);

        vault.IsSendingChosenHostsToAVault.ShouldBeFalse();
        vault.Status.ShouldContain("only keychain");
        vault.IsChoosingHosts.ShouldBeTrue("and the selection is left alone to be used for something else");
    }

    /// <remarks>
    /// The bar's own CONNECT, which is the entry a tap already is — it is in the menu because the bar is
    /// what a long press leaves you in, and without it connecting to the machine you had just chosen would
    /// mean leaving selection mode first.
    /// </remarks>
    [Fact]
    public async Task ConnectingFromTheActionBar_OpensTheHostAndLeavesSelectionMode()
    {
        var vault = await ReadyToConnectAsync();

        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

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

        await vault.ConnectToChosenHostCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("Connected", Case.Insensitive);
        vault.IsChoosingHosts.ShouldBeFalse();
    }

    /// <remarks>
    /// ◆ <b>"Connect via SFTP", which crosses from the vault to the shell.</b> Which machine is a decrypted
    /// item and so is the vault's; the screen it leads to and the transfers view model behind it are the
    /// shell's. This pins the join — the host arrives chosen in the file screen's own list, which is a copy
    /// rebuilt from the vault's, so handing it the vault's row object would select nothing.
    /// </remarks>
    [Fact]
    public async Task BrowsingAChosenHost_GoesToTheFilesScreenWithThatHostChosen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        vault.BrowseChosenHostCommand.Execute(null);

        shell.IsTransfersShowing.ShouldBeTrue();
        shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
        vault.IsChoosingHosts.ShouldBeFalse();

        // A host with no key and no credential wants a typed password, and there is nowhere on a list to
        // give it one — so the picker opens with the machine already chosen and the box beside it, which is
        // the same branch a tap on the hosts screen makes.
        shell.Transfers.IsChoosingRemote.ShouldBeTrue();
        shell.Transfers.Status.ShouldContain("password");
    }

    // ---- The v5b session sidebar's QUICK ACCESS ----

    /// <summary>
    /// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
    /// the connection already open and the tab it opened already selected.
    /// </summary>
    private async Task<VaultViewModel> ConnectedHostWithAPinAsync(string path = "/var/www/app")
    {
        var vault = await ReadyToConnectAsync();

        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorNewPin = path;
        vault.AddEditorPinCommand.Execute(null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        await vault.ConnectToChosenHostCommand.ExecuteAsync(null);

        return vault;
    }

    [Fact]
    public async Task TheSidebar_ShowsTheConnectedTabsHostsPins()
    {
        await ConnectedHostWithAPinAsync();

        shell.ShowsQuickAccessSidebar.ShouldBeTrue();
        shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
    }

    [Fact]
    public async Task TheSidebar_StaysHiddenBeforeAnythingConnects()
    {
        var vault = await ReadyToConnectAsync();

        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        // Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed
        // to a selected tab, not to the host that happens to be selected on the hosts screen.
        shell.ShowsQuickAccessSidebar.ShouldBeFalse();
        shell.ActiveTabPinnedPaths.ShouldBeEmpty();
    }

    /// <remarks>
    /// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar
    /// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on
    /// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty
    /// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did.
    /// </remarks>
    [Fact]
    public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins()
    {
        var vault = await ReadyToConnectAsync();

        await AddKeyAsync(vault, "deploy");
        await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
        await vault.ConnectToChosenHostCommand.ExecuteAsync(null);

        shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing");
        shell.ActiveTabPinnedPaths.ShouldBeEmpty();
    }

    [Fact]
    public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp()
    {
        await ConnectedHostWithAPinAsync();

        shell.ShowsQuickAccessSidebar.ShouldBeTrue();

        shell.ShowScreenCommand.Execute(ShellScreen.Preferences);

        shell.ShowsQuickAccessSidebar.ShouldBeFalse(
            "a page is showing, not the terminal or SFTP the sidebar sits beside");

        shell.SelectTabCommand.Execute(shell.Tabs[0]);

        shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected");
    }

    /// <remarks>
    /// The other surface the sidebar draws on since v5b: SFTP, gated on <c>Transfers.IsConnected</c> rather
    /// than on a selected tab, since a session on that screen is its own connection — see
    /// <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>.
    /// </remarks>
    [Fact]
    public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected()
    {
        await ConnectedHostWithAPinAsync();

        await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");

        shell.IsTransfersShowing.ShouldBeTrue();
        shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now");
    }

    /// <remarks>
    /// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the
    /// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
    /// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
    /// really are the second authenticated connection the design docs say they are — SftpRequests gets an
    /// entry independent of Requests.
    /// </remarks>
    [Fact]
    public async Task ClickingAPinChip_OpensFilesAtThatPath()
    {
        var vault = await ConnectedHostWithAPinAsync();

        await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");

        shell.IsTransfersShowing.ShouldBeTrue();
        shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
        shell.Transfers.IsConnected.ShouldBeTrue();
        shell.Transfers.RemotePath.ShouldBe("/var/www/app");

        ssh.SftpRequests.ShouldHaveSingleItem();
        vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
    }

    // ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ----

    /// <remarks>
    /// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses —
    /// see the deviation recorded on <c>MainWindowViewModel.SelectFilesHostCommand</c>.
    /// </remarks>
    [Fact]
    public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected()
    {
        await ConnectedHostWithAPinAsync();

        var tab = shell.Tabs[0];
        shell.SelectedTab = null;

        await shell.SelectFilesHostCommand.ExecuteAsync(tab);

        shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected");
        shell.IsTransfersShowing.ShouldBeTrue();
        shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
        shell.Transfers.IsConnected.ShouldBeTrue();
    }

    /// <remarks>
    /// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface
    /// directions the v5b notes ask for, through <c>VaultViewModel.ConnectCommand</c> rather than through a
    /// tab that does not exist.
    /// </remarks>
    [Fact]
    public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost()
    {
        await ConnectedHostWithAPinAsync();
        await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");

        shell.Transfers.IsConnected.ShouldBeTrue();
        var tabsBefore = shell.Tabs.Count;

        await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null);

        shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host");
        shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db");
        shell.IsTerminalShowing.ShouldBeTrue();
    }

    /// <remarks>
    /// v5c-4 retired the session shell's own header row and moved its cross-surface button into the sidebar,
    /// where one control is drawn on both surfaces — so the two directions above are reached through one
    /// command and one label, resolved by the shell. This is that resolution: the same two outcomes the two
    /// tests above assert, from the row a user actually clicks now.
    /// </remarks>
    [Fact]
    public async Task TheSidebarsCrossSurfaceRow_NamesAndOpensWhicheverSurfaceIsNotShowing()
    {
        await ConnectedHostWithAPinAsync();

        shell.IsTerminalSurface.ShouldBeTrue();
        shell.SessionCrossSurfaceLabel.ShouldBe("Open SFTP");

        await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);

        shell.IsTransfersShowing.ShouldBeTrue();
        shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
        shell.SessionCrossSurfaceLabel.ShouldBe("Open terminal", "the row turns over with the surface");

        var tabsBefore = shell.Tabs.Count;

        await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);

        shell.Tabs.Count.ShouldBe(tabsBefore + 1, "the other direction dials a terminal at the browsed host");
        shell.IsTerminalShowing.ShouldBeTrue();
    }

    /// <remarks>
    /// Closing the sidebar is a preference about this machine, so it outlives the window — see
    /// <c>ClientSettings.SessionSidebarOpen</c>. Asserted against the store rather than against a second
    /// shell built over the same profile: what a fresh launch reads is exactly what is on disk, and building
    /// another shell here would prove the constructor twice and the storage once.
    /// </remarks>
    [Fact]
    public async Task ClosingTheSidebar_IsRememberedForTheNextLaunch()
    {
        await ConnectedHostWithAPinAsync();

        shell.IsSessionSidebarOpen.ShouldBeTrue("open is the default, and nothing has closed it");

        shell.ToggleSessionSidebarCommand.Execute(null);

        shell.IsSessionSidebarOpen.ShouldBeFalse();
        new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeFalse();

        shell.ToggleSessionSidebarCommand.Execute(null);

        shell.IsSessionSidebarOpen.ShouldBeTrue();
        new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeTrue("and reopening is remembered too");
    }

    /// <remarks>
    /// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
    /// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
    /// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
    /// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
    /// that same mechanism rather than reimplementing it.
    ///
    /// The focus request is asserted here rather than in a test of its own because it is part of what this
    /// click does: the row that typed the command took the keyboard with it, and only the window can give it
    /// back. See <c>MainWindowViewModel.TerminalFocusRequested</c>.
    /// </remarks>
    [Fact]
    public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
    {
        await ConnectedHostWithAPinAsync();

        var snippets = shell.SnippetsScreen.ShouldNotBeNull();
        await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);

        var row = snippets.Visible.ShouldHaveSingleItem();

        var focusRequests = 0;
        shell.TerminalFocusRequested += (_, _) => focusRequests++;

        await shell.InsertSnippetCommand.ExecuteAsync(row);

        snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
        focusRequests.ShouldBe(1, "the keyboard goes back to the terminal the command landed in");
    }

    /// <remarks>
    /// The other half of the rule above: nothing was typed, so nothing asks for the keyboard. A snip clicked
    /// with no terminal to put it in lands on the snippets screen instead — see
    /// <c>MainWindowViewModel.InsertSnippetCommand</c> — and stealing focus into a collapsed WebView on the
    /// way would leave that screen unable to be typed on.
    /// </remarks>
    [Fact]
    public async Task InsertingASnippetWithNothingToInsertInto_DoesNotAskForTheTerminal()
    {
        await UnlockedAsync();

        var snippets = shell.SnippetsScreen.ShouldNotBeNull();
        await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);

        var row = snippets.Visible.ShouldHaveSingleItem();

        var focusRequests = 0;
        shell.TerminalFocusRequested += (_, _) => focusRequests++;

        await shell.InsertSnippetCommand.ExecuteAsync(row);

        shell.Screen.ShouldBe(ShellScreen.Snippets);
        focusRequests.ShouldBe(0);
    }

    [Fact]
    public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen()
    {
        await UnlockedAsync();

        shell.AddSnippetFromSidebarCommand.Execute(null);

        shell.IsSnippetsShowing.ShouldBeTrue();
        shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue();
    }

    /// <remarks>
    /// The closest honest affordance the v5b notes ask for: this application cannot open a host editor
    /// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as
    /// the hosts screen's own EDIT does.
    /// </remarks>
    [Fact]
    public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor()
    {
        await ConnectedHostWithAPinAsync();
        var vault = shell.Vault!;

        shell.PinFolderFromSidebarCommand.Execute(null);

        shell.IsHostsShowing.ShouldBeTrue();
        vault.IsEditing.ShouldBeTrue();
        vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
    }

    /// <remarks>
    /// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
    /// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
    /// opens, and this reads it back through the same clock. The cipher and host-key algorithm are the
    /// fake's own, and the identity text is the bound key's label — <c>ConnectedHostWithAPinAsync</c> binds
    /// "deploy" — composed with the real (unshortened) algorithm string.
    /// </remarks>
    [Fact]
    public async Task SessionFacts_ReflectTheSelectedTerminalTab()
    {
        await ConnectedHostWithAPinAsync();

        shell.IsSessionConnected.ShouldBeTrue();
        shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
        shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
        shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
        shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
        shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");
    }

    /// <remarks>
    /// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than
    /// a blank or a placeholder standing in for them.
    /// </remarks>
    [Fact]
    public async Task SessionFacts_AreAbsentWithNoSessionOpen()
    {
        await UnlockedAsync();

        shell.IsSessionConnected.ShouldBeFalse();
        shell.SessionAddress.ShouldBeNull();
        shell.SessionElapsedText.ShouldBeNull();
        shell.SessionCipher.ShouldBeNull();
        shell.SessionHostKeyAlgorithm.ShouldBeNull();
        shell.SessionIdentityText.ShouldBeNull();
    }

    /// <remarks>
    /// A typed password has nothing filed in the keychain to name, so the status bar's identity run is the
    /// host-key algorithm alone — no " · " and nothing after it, which is what
    /// <see cref="MainWindowViewModel.SessionIdentityText"/>'s own remark promises rather than a placeholder
    /// standing in for the item that was never there.
    /// </remarks>
    [Fact]
    public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        vault.ManualTarget = "deploy@build.internal:2222";
        vault.ManualPassword = "hunter2";

        await vault.ConnectManuallyCommand.ExecuteAsync(null);

        shell.IsSessionConnected.ShouldBeTrue();
        shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
        shell.SessionIdentityText.ShouldBe("ssh-ed25519", "a typed password names no keychain item");
    }

    /// <remarks>
    /// The same facts on the other surface, and one asymmetry worth pinning: the SFTP session is its own
    /// login through the same resolution ladder, so the status bar names that session's cipher, host key and
    /// identity — and stops naming them on disconnect, where a terminal tab keeps its facts for the
    /// scrollback still on screen (see <c>TerminalTabViewModel.Cipher</c>'s remark).
    /// </remarks>
    [Fact]
    public async Task SessionFacts_FollowTheSftpSurfacesOwnSession()
    {
        await ConnectedHostWithAPinAsync();

        await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");

        shell.IsTransfersShowing.ShouldBeTrue();
        shell.SessionCipher.ShouldBe("aes256-gcm@openssh.com");
        shell.SessionIdentityText.ShouldBe("ssh-ed25519 · deploy");

        await shell.Transfers.DisconnectCommand.ExecuteAsync(null);

        shell.SessionCipher.ShouldBeNull("the SFTP surface has no scrollback for a dead session's facts to describe");
        shell.SessionIdentityText.ShouldBeNull();
    }

    [Fact]
    public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
    {
        // Both heads' only route into a group editor since v5: neither draws a group card to select one
        // from any more, so the command has to work off the heading it was pressed on. Two groups exist here
        // so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "estate");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
            row => string.Equals(row.Label, "production", StringComparison.Ordinal));

        vault.EditGroupFromHeadingCommand.Execute(heading);

        vault.IsEditingGroup.ShouldBeTrue();
        vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group");
    }

    /// <remarks>
    /// The heading hands its group straight to the editor rather than selecting a card first, and this is
    /// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
    /// mark, and neither head has drawn a group card to select since v5 — see <c>EditGroup</c>'s own remarks.
    /// </remarks>
    [Fact]
    public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        var host = vault.Hosts.Single();
        vault.SelectedHost = host;

        var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
            row => string.Equals(row.Label, "production", StringComparison.Ordinal));

        vault.EditGroupFromHeadingCommand.Execute(heading);

        vault.IsEditingGroup.ShouldBeTrue("the editor still opens on the group the heading names");
        vault.GroupEditorLabel.ShouldBe("production");
        vault.SelectedHost.ShouldBeSameAs(host, "and the list is still on the machine it was on");
    }

    [Fact]
    public async Task TheUngroupedHeading_OpensNothing()
    {
        // It has no group behind it. The button is hidden there, so this is the guard for the path the
        // markup does not control.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");

        var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
            .FirstOrDefault(row => row.GroupId is null);

        ungrouped.ShouldNotBeNull("a vault with a group and an unfiled host draws an ungrouped heading");

        vault.EditGroupFromHeadingCommand.Execute(ungrouped);

        vault.IsEditingGroup.ShouldBeFalse();
    }

    /// <remarks>
    /// <para>
    /// The load-bearing half of giving the phone a group menu, and the reason the commands take a header at
    /// all. <c>DeleteGroup</c> and <c>MoveGroup</c> aim at <c>GroupTarget</c>, which is the selected card or
    /// the open group — and the phone has neither, because its list draws headings and a heading is not a
    /// thing that list can select. Called bare on that head they would return having done nothing, which is
    /// a DELETE that appears to have been pressed and has not.
    /// </para>
    /// <para>
    /// The tick is asserted <em>off</em> as well as present. Off is "the machines stay and turn up under
    /// UNGROUPED", which is recoverable; on is not, and a question that arrived with the destructive answer
    /// already given would be worse than one that never asked.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AGroupsHeading_AsksAboutThatGroupWithNothingSelected()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
            row => string.Equals(row.Label, "production", StringComparison.Ordinal));

        vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");

        vault.OpenGroupSheetCommand.Execute(heading);

        vault.GroupSheetLabel.ShouldBe("production", "the menu names what it is about");
        vault.AnEditorIsOpen.ShouldBeTrue("so the + stands down, as it does under the add sheet");

        vault.DeleteGroupFromHeadingCommand.Execute(heading);

        vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
        vault.IsConfirmingGroupDeletion.ShouldBeTrue();
        vault.PendingDeletion!.Question.ShouldContain("production");
        vault.PendingDeletion.HasChoice.ShouldBeTrue("a host is filed under it, so it has a second question");
        vault.DeletionTakesTheHostsToo.ShouldBeFalse("keeping them is the answer that needs no decision");
    }

    /// <remarks>
    /// A menu is a thing you are allowed to decide against, which is why this one is dismissible where the
    /// host key sheet deliberately is not. Nothing may be left armed behind it.
    /// </remarks>
    [Fact]
    public async Task TheGroupsMenu_WavedAway_LeavesEverythingAsItWas()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");
        await FileAsync(vault, "prod-db", "production");

        var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
            row => string.Equals(row.Label, "production", StringComparison.Ordinal));

        vault.OpenGroupSheetCommand.Execute(heading);
        vault.CloseGroupSheetCommand.Execute(null);

        vault.GroupSheet.ShouldBeNull();
        vault.AnEditorIsOpen.ShouldBeFalse();
        vault.IsConfirmingDeletion.ShouldBeFalse();
        vault.IsMovingGroup.ShouldBeFalse();
        vault.IsEditingGroup.ShouldBeFalse();
    }

    [Fact]
    public async Task TheUngroupedHeading_RaisesNoMenu()
    {
        // Nothing behind it for the three entries to act on. The button is left off that row, so this is the
        // guard for the path the markup does not control.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");

        var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
            .Single(row => row.GroupId is null);

        vault.OpenGroupSheetCommand.Execute(ungrouped);

        vault.GroupSheet.ShouldBeNull();
    }

    // ---- Deleting a host, from the phone's bar ----

    /// <remarks>
    /// ◆ <b>The three panels the action bar's menu can raise, and the rule that at most one is up.</b> They
    /// are drawn above the list rather than over it — the ticked rows are the information the question exists
    /// to give — so each one has to disarm the other two on the way up, or two questions about the same six
    /// machines would be stacked, one of them destructive.
    /// </remarks>
    [Fact]
    public async Task TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddGroupAsync(vault, "production");

        vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));

        vault.DeleteChosenHostsCommand.Execute(null);
        vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();

        vault.RegroupChosenHostsCommand.Execute(null);

        vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
        vault.IsConfirmingChosenHostDeletion.ShouldBeFalse("the question was disarmed on the way up");

        vault.DeleteChosenHostsCommand.Execute(null);

        vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
        vault.IsRegroupingChosenHosts.ShouldBeFalse("and the picker folded away in return");
    }

    // ---- Tags ----
    //
    // The type has been storable since the domain landed and unreachable until now. What these pin is the
    // two halves of making it reachable: a chip is a name resolved through the tag list, and a picker is a
    // set the host editor edits like any other field.

    [Fact]
    public async Task ATagPutOnAHost_ShowsAsAChipAndSurvivesAReload()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");

        await TagAsync(vault, "prod-db", "pci");

        Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]);
        Host(vault, "prod-db").HasTags.ShouldBeTrue();
        Host(vault, "prod-db").Host.TagIds.Count.ShouldBe(1);
    }

    [Fact]
    public async Task RenamingATag_ChangesEveryChipAndRewritesNoHost()
    {
        // The whole reason a tag is an item rather than a string on a host. If this ever needed to touch a
        // host, the type would have earned nothing over repeating the name inside twenty payloads.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddHostAsync(vault, "stage-web");
        await AddTagAsync(vault, "pci");
        await TagAsync(vault, "prod-db", "pci");
        await TagAsync(vault, "stage-web", "pci");

        var before = Host(vault, "prod-db").Host;

        vault.SelectedTag = vault.Tags.Single();
        vault.EditTagCommand.Execute(null);
        vault.TagEditorLabel = "pci-dss";
        await vault.SaveTagCommand.ExecuteAsync(null);

        Host(vault, "prod-db").TagLabels.ShouldBe(["pci-dss"]);
        Host(vault, "stage-web").TagLabels.ShouldBe(["pci-dss"]);

        Host(vault, "prod-db").Host.ShouldBe(before, "renaming a tag must not rewrite a host");
    }

    [Fact]
    public async Task DeletingATag_LeavesTheHostsAloneAndTheChipsGone()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");
        await TagAsync(vault, "prod-db", "pci");

        var wornBefore = Host(vault, "prod-db").Host.TagIds;

        vault.SelectedTag = vault.Tags.Single();
        vault.DeleteTagCommand.Execute(null);

        vault.PendingDeletion.ShouldNotBeNull().Usage
            .ShouldContain("1 host", Case.Insensitive, "the count is what makes this decidable");

        await vault.ConfirmDeleteCommand.ExecuteAsync(null);

        vault.Tags.ShouldBeEmpty();

        // The chip is gone and the id is not. Nothing rewrites N payloads inside one delete, so the host
        // still names a tag that resolves to nothing — and would wear it again if the tag came back.
        Host(vault, "prod-db").TagLabels.ShouldBeEmpty();
        Host(vault, "prod-db").Host.TagIds.ShouldBe(wornBefore);
    }

    /// <remarks>
    /// v5c-2: the settings Tags page has no list selection to lean on the way the keychain screen's own
    /// table does, so <c>EditTagRow</c>/<c>DeleteTagRow</c> select the row and then hand off to the real
    /// commands above — this proves the hand-off reaches the same place, with the same guard sentences.
    /// </remarks>
    [Fact]
    public async Task EditTagRow_SelectsTheRowThenOpensTheSameEditorEditTagDoes()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddTagAsync(vault, "pci");
        var row = vault.Tags.Single();

        vault.EditTagRowCommand.Execute(row);

        vault.SelectedTag.ShouldBe(row);
        vault.IsEditingTag.ShouldBeTrue();
        vault.TagEditorLabel.ShouldBe("pci");
    }

    [Fact]
    public async Task DeleteTagRow_SelectsTheRowThenArmsTheSameConfirmationDeleteTagDoes()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");
        await TagAsync(vault, "prod-db", "pci");

        var row = vault.Tags.Single();

        vault.DeleteTagRowCommand.Execute(row);

        vault.SelectedTag.ShouldBe(row);
        vault.PendingDeletion.ShouldNotBeNull().Usage
            .ShouldContain("1 host", Case.Insensitive, "the same guard sentence DeleteTag would have armed");
    }

    [Fact]
    public async Task ATagCreatedFromTheHostEditor_IsPutOnTheHostBeingEdited()
    {
        // Where a tag is usually wanted: while tagging a host and finding it does not exist yet.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.HasTagChoices.ShouldBeFalse("nothing to offer in a keychain with no tags");

        vault.EditorNewTag = "eu-west-1";
        await vault.AddEditorTagCommand.ExecuteAsync(null);

        vault.EditorNewTag.ShouldBeEmpty("the box empties so a second one can be typed straight away");
        vault.EditorTagChoices.ShouldHaveSingleItem().IsWorn.ShouldBeTrue();

        await vault.SaveHostCommand.ExecuteAsync(null);

        Host(vault, "prod-db").TagLabels.ShouldBe(["eu-west-1"]);
    }

    [Fact]
    public async Task ATagTypedTwice_IsUsedRatherThanRepeated()
    {
        // Two tags called "staging" are storable and must stay storable — two people creating one offline
        // is how it happens. Typing the same name into this box is a slip rather than an intention.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewTag = "PCI";
        await vault.AddEditorTagCommand.ExecuteAsync(null);

        vault.Tags.ShouldHaveSingleItem().Label.ShouldBe("pci", "matched regardless of case");
        vault.EditorTagChoices.ShouldHaveSingleItem().IsWorn.ShouldBeTrue();
    }

    [Fact]
    public async Task ATagTakenOffAHost_LeavesTheTagInTheKeychain()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");
        await TagAsync(vault, "prod-db", "pci");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.ToggleEditorTagCommand.Execute(vault.EditorTagChoices.Single());
        vault.EditorTagChoices.Single().IsWorn.ShouldBeFalse();

        await vault.SaveHostCommand.ExecuteAsync(null);

        Host(vault, "prod-db").TagLabels.ShouldBeEmpty();
        vault.Tags.ShouldHaveSingleItem().HostCount.ShouldBe(0);
    }

    [Fact]
    public async Task CancellingAHostEdit_DropsTheTaggingAndKeepsTheTag()
    {
        // The one asymmetry worth pinning. Tagging is a field on the host and goes with a cancel; creating
        // the tag wrote to the keychain immediately, because a host can only name an id that exists.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewTag = "pci";
        await vault.AddEditorTagCommand.ExecuteAsync(null);

        vault.CancelEditCommand.Execute(null);

        vault.Tags.ShouldHaveSingleItem().Label.ShouldBe("pci", "the tag was never part of the host");
        Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
    }

    // ---- Pinned paths (QUICK ACCESS) ----

    [Fact]
    public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorPinnedPaths.ShouldBeEmpty();

        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);

        vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
        vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);

        await vault.SaveHostCommand.ExecuteAsync(null);

        Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
    }

    [Fact]
    public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
    {
        // Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
        // to preserve it as faithfully as the domain type it is about to become.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
        {
            vault.EditorNewPin = path;
            vault.AddEditorPinCommand.Execute(null);
        }

        await vault.SaveHostCommand.ExecuteAsync(null);

        Host(vault, "prod-db").Host.PinnedPaths
            .ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
    }

    [Fact]
    public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        vault.EditorNewPin = "/etc/nginx";
        vault.AddEditorPinCommand.Execute(null);

        vault.RemoveEditorPinCommand.Execute("/var/www/app");

        vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);

        await vault.SaveHostCommand.ExecuteAsync(null);

        Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
    }

    [Fact]
    public async Task CancellingAHostEdit_DropsThePinningEntirely()
    {
        // Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
        // than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
        // Tag holds a tag's own name to.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);

        vault.CancelEditCommand.Execute(null);

        Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
    }

    [Fact]
    public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
    }

    [Fact]
    public async Task ANewHostsEditor_OpensWithNoPinsStaged()
    {
        // NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
        // reason every other editor field is reset there.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.NewHostCommand.Execute(null);

        vault.EditorPinnedPaths.ShouldBeEmpty();
    }

    [Fact]
    public async Task ABlankPin_IsRefusedAtTheAddBox()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewPin = "   ";
        vault.AddEditorPinCommand.Execute(null);

        vault.EditorPinnedPaths.ShouldBeEmpty();
        vault.Status.ShouldContain("blank");
    }

    [Fact]
    public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);

        vault.EditorPinnedPaths.ShouldHaveSingleItem();
        vault.Status.ShouldContain("already pinned");
    }

    [Fact]
    public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
    {
        // The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
        // that waits for SAVE throws away every other field typed on the form since, where one at the box
        // that caused it costs nothing else.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
        vault.AddEditorPinCommand.Execute(null);

        vault.EditorPinnedPaths.ShouldBeEmpty();
        vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
    }

    [Fact]
    public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);

        for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
        {
            vault.EditorNewPin = $"/pin/{i}";
            vault.AddEditorPinCommand.Execute(null);
        }

        vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);

        vault.EditorNewPin = "/one/too/many";
        vault.AddEditorPinCommand.Execute(null);

        vault.EditorPinnedPaths.Count.ShouldBe(
            HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
        vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
    }

    [Fact]
    public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
    {
        // BuildHost rebuilds the whole record from the editor's state, so a tag set that was not carried
        // through would be stripped by an edit about something else entirely.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");
        await TagAsync(vault, "prod-db", "pci");

        vault.SelectedHost = Host(vault, "prod-db");
        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorPort = 2222;
        await vault.SaveHostCommand.ExecuteAsync(null);

        Host(vault, "prod-db").TagLabels.ShouldBe(["pci"]);
    }

    [Fact]
    public async Task TheKeychainTable_ShowsTagsAndCountsThemUnderAll()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        await AddTagAsync(vault, "pci");
        await TagAsync(vault, "prod-db", "pci");

        vault.Section = VaultSection.Tags;

        var row = vault.VaultItems.ShouldHaveSingleItem();
        row.Name.ShouldBe("pci");
        row.Type.ShouldBe("TAG");
        row.Detail.ShouldBe("1 host");

        vault.Section = VaultSection.All;

        vault.TotalItemCount.ShouldBe(
            vault.VaultItems.Count, "the ALL chip counts what ALL shows");
    }

    [Fact]
    public async Task OpeningTheTagEditor_TakesAnArmedDeletionAway()
    {
        // Every other editor on this screen disarms a pending question when it opens, because the vault
        // screen's Add buttons stay live beside the detail pane. Without it the tag's boxes would render
        // directly under a confirmation belonging to an item the user is no longer looking at, and its
        // DELETE would still be live.
        await UnlockedAsync();
        var vault = shell.Vault!;

        await AddKeyAsync(vault, "deploy");

        vault.Section = VaultSection.Keys;
        vault.SelectedVaultItem = vault.VaultItems.Single();
        vault.DeleteSelectedItemCommand.Execute(null);

        vault.PendingDeletion.ShouldNotBeNull();

        vault.NewTagCommand.Execute(null);

        vault.IsEditingTag.ShouldBeTrue();
        vault.PendingDeletion.ShouldBeNull("the question went with the editor opening");
    }

    private static async Task AddTagAsync(VaultViewModel vault, string label)
    {
        vault.NewTagCommand.Execute(null);
        vault.TagEditorLabel = label;

        await vault.SaveTagCommand.ExecuteAsync(null);
    }

    /// <summary>Puts a tag on a host the way a user can: through the host's own editor.</summary>
    private static async Task TagAsync(VaultViewModel vault, string host, string tag)
    {
        vault.SelectedHost = Host(vault, host);
        vault.EditSelectedHostCommand.Execute(null);

        vault.ToggleEditorTagCommand.Execute(vault.EditorTagChoices.Single(
            choice => string.Equals(choice.Label, tag, StringComparison.Ordinal)));

        await vault.SaveHostCommand.ExecuteAsync(null);
    }

    private static async Task SetGroupDefaultsAsync(
        VaultViewModel vault,
        string group,
        int? port = null,
        string? username = null,
        string? key = null)
    {
        vault.EditGroupCommand.Execute(vault.Groups.Single(
            row => string.Equals(row.Label, group, StringComparison.Ordinal)));

        vault.GroupEditorDefaultPort = port;
        vault.GroupEditorDefaultUsername = username ?? string.Empty;

        if (key is not null)
        {
            vault.GroupEditorSelectedAuthentication = vault.GroupEditorAuthenticationChoices.Single(
                choice => string.Equals(choice.Label, key, StringComparison.Ordinal));
        }

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
    {
        vault.EditGroupCommand.Execute(vault.Groups.Single(
            row => string.Equals(row.Label, group, StringComparison.Ordinal)));

        vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
            choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    // ---- Snippets ----

    /// <remarks>
    /// The default that the whole feature's safety rests on. A snippet somebody writes without thinking
    /// about the flag has to be one that gets typed and waits, because the alternative is a command that
    /// runs the first time it is clicked.
    /// </remarks>
    [Fact]
    public async Task ANewSnippet_DoesNotRunOnItsOwn()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;
        var snippets = shell.SnippetsScreen.ShouldNotBeNull();

        snippets.NewCommand.Execute(null);

        snippets.EditorRunsOnInsert.ShouldBeFalse("the box starts off");

        snippets.EditorLabel = "restart the api";
        snippets.EditorCommand = "sudo systemctl restart dodossh-api";
        await snippets.SaveCommand.ExecuteAsync(null);

        vault.Snippets.ShouldHaveSingleItem().RunsOnInsert.ShouldBeFalse();
    }

    /// <remarks>
    /// A here-document's terminator has to arrive on a line of its own with nothing after it. Trim the
    /// trailing newline and the shell waits for one that never comes, which reads to the user as the snippet
    /// having hung the terminal — so the command is stored exactly as typed, in the same way key armour is.
    /// </remarks>
    [Fact]
    public async Task ASnippetsText_IsStoredExactlyAsTyped()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;
        var snippets = shell.SnippetsScreen.ShouldNotBeNull();

        const string Command = "cat <<'EOF' > /etc/motd\n  welcome  \nEOF\n";

        snippets.NewCommand.Execute(null);
        snippets.EditorLabel = "  set the motd  ";
        snippets.EditorCommand = Command;
        await snippets.SaveCommand.ExecuteAsync(null);

        var stored = vault.Snippets.ShouldHaveSingleItem();

        stored.Snippet.Command.ShouldBe(Command);
        stored.Label.ShouldBe("set the motd", "the name is trimmed, and only the name");
    }

    [Fact]
    public async Task InsertingASnippet_SendsItsTextToTheSelectedTabWithoutRunningIt()
    {
        await UnlockedAsync();

        var sent = new List<(uint SessionId, string Text, bool Execute)>();
        var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent);

        await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);

        snippets.Selected = snippets.Visible.Single();
        snippets.CanInsert.ShouldBeTrue();

        await snippets.InsertCommand.ExecuteAsync(null);

        var delivered = sent.ShouldHaveSingleItem();

        delivered.SessionId.ShouldBe(7u);
        delivered.Text.ShouldBe("uptime");
        delivered.Execute.ShouldBeFalse("INSERT types the command and stops");
    }

    /// <remarks>
    /// RUN is offered only for a snippet whose own flag says it runs, so that "this one runs" is a decision
    /// taken once while writing it. Pressing the command for a snippet without the flag has to do nothing —
    /// not throw, and above all not send.
    /// </remarks>
    [Fact]
    public async Task RunningASnippet_IsRefusedUnlessTheSnippetSaysItRuns()
    {
        await UnlockedAsync();

        var sent = new List<(uint SessionId, string Text, bool Execute)>();
        var snippets = SnippetsOver(shell.Vault!, new InsertTarget(7, "prod-db"), sent);

        await AddSnippetAsync(snippets, "safe", "ls -la", runs: false);
        await AddSnippetAsync(snippets, "armed", "sudo reboot", runs: true);

        snippets.Selected = snippets.Visible.Single(row => !row.RunsOnInsert);
        snippets.SelectionRuns.ShouldBeFalse();

        await snippets.RunCommand.ExecuteAsync(null);
        sent.ShouldBeEmpty("this snippet is not one that runs");

        snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
        snippets.SelectionRuns.ShouldBeTrue();

        await snippets.RunCommand.ExecuteAsync(null);

        sent.ShouldHaveSingleItem().Execute.ShouldBeTrue();
    }

    [Fact]
    public async Task InsertingWithNoTerminalOpen_SaysSoAndSendsNothing()
    {
        await UnlockedAsync();

        var sent = new List<(uint SessionId, string Text, bool Execute)>();
        var snippets = SnippetsOver(shell.Vault!, InsertTarget.None, sent);

        await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);

        snippets.Selected = snippets.Visible.Single();

        snippets.CanInsert.ShouldBeFalse();
        snippets.InsertLabel.ShouldBe("NO TERMINAL OPEN");

        await snippets.InsertCommand.ExecuteAsync(null);

        sent.ShouldBeEmpty();
        snippets.Status.ShouldContain("Open a terminal first", Case.Sensitive);
    }

    /// <remarks>
    /// The transport drops frames for a pane nothing is listening to, so a send at a tab whose remote hung
    /// up succeeds exactly as loudly as one at a live tab. That is why the insert reports back — and why the
    /// screen has to say so rather than leaving somebody to wonder whether the command landed.
    /// </remarks>
    [Fact]
    public async Task InsertingIntoATabThatIsNoLongerConnected_SaysSo()
    {
        await UnlockedAsync();

        var snippets = new SnippetsViewModel(
            shell.Vault!,
            () => new InsertTarget(7, "prod-db"),
            static (_, _, _, _) => Task.FromResult(false));

        await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);

        snippets.Selected = snippets.Visible.Single();
        await snippets.InsertCommand.ExecuteAsync(null);

        snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
    }

    /// <remarks>
    /// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see
    /// <c>SnippetsViewModel.RequestDelete</c>'s own remarks for why the two are separate commands. Pins the
    /// two-step shape every other keychain item's deletion already has: arming changes nothing, and only
    /// confirming actually removes it.
    /// </remarks>
    [Fact]
    public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed()
    {
        await UnlockedAsync();
        var snippets = shell.SnippetsScreen.ShouldNotBeNull();

        await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false);

        snippets.Selected = snippets.Visible.Single();
        snippets.RequestDeleteCommand.Execute(null);

        snippets.IsConfirmingDelete.ShouldBeTrue();
        snippets.DeleteQuestion.ShouldContain("restart the api");
        snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place");
        snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself");

        snippets.CancelDeleteCommand.Execute(null);

        snippets.IsConfirmingDelete.ShouldBeFalse();
        snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was");

        snippets.RequestDeleteCommand.Execute(null);
        await snippets.ConfirmDeleteCommand.ExecuteAsync(null);

        snippets.IsConfirmingDelete.ShouldBeFalse();
        snippets.Visible.ShouldBeEmpty();
    }

    private static SnippetsViewModel SnippetsOver(
        VaultViewModel vault,
        InsertTarget target,
        List<(uint SessionId, string Text, bool Execute)> sent) =>
        new(
            vault,
            () => target,
            (sessionId, text, execute, _) =>
            {
                sent.Add((sessionId, text, execute));
                return Task.FromResult(true);
            });

    private static async Task AddSnippetAsync(
        SnippetsViewModel snippets,
        string label,
        string command,
        bool runs)
    {
        snippets.NewCommand.Execute(null);
        snippets.EditorLabel = label;
        snippets.EditorCommand = command;
        snippets.EditorRunsOnInsert = runs;

        await snippets.SaveCommand.ExecuteAsync(null);
    }

    private static string[] Headings(VaultViewModel vault) =>
        [.. vault.SidebarRows.OfType<SidebarGroupHeader>()
            .Where(header => header.GroupId is not null)
            .Select(header => header.Label)];

    private static async Task AddGroupAsync(VaultViewModel vault, string label)
    {
        vault.GroupEditorLabel = label;

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    /// <summary>Files a host into a group the way a user can: through the host's own editor.</summary>
    private static async Task FileAsync(VaultViewModel vault, string host, string group)
    {
        vault.SelectedHost = vault.Hosts.Single(
            row => string.Equals(row.Label, host, StringComparison.Ordinal));

        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, group, StringComparison.Ordinal));

        await vault.SaveHostCommand.ExecuteAsync(null);
    }

    // ---- Logs ----

    /// <remarks>
    /// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own
    /// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins
    /// that <c>LogSection</c> genuinely has only the two values the segments name, and that the two flags a
    /// segment's own active state reads are mutually exclusive.
    /// </remarks>
    [Fact]
    public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections()
    {
        await UnlockedAsync();
        var logs = shell.LogsScreen.ShouldNotBeNull();

        logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections");
        logs.ShowsConnections.ShouldBeTrue();
        logs.ShowsActivity.ShouldBeFalse();

        logs.ShowSectionCommand.Execute(LogSection.Activity);

        logs.ShowsActivity.ShouldBeTrue();
        logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true");

        logs.ShowSectionCommand.Execute(LogSection.Connections);
        logs.ShowsConnections.ShouldBeTrue();

        Enum.GetValues<LogSection>().Length.ShouldBe(2, "the design draws two segments and the enum has two");
    }

    /// <remarks>
    /// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per-
    /// section fact this type's header comment already states truthfully, and steps aside for a refresh
    /// error when refreshing just produced one.
    /// </remarks>
    [Fact]
    public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError()
    {
        await UnlockedAsync();
        var logs = shell.LogsScreen.ShouldNotBeNull();

        logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes");

        logs.ShowSectionCommand.Execute(LogSection.Activity);
        logs.HeaderStatusLine.ShouldBe("one row per write, per device");

        logs.Status = "network unreachable";
        logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact");
    }

    // ---- Helpers ----

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

    /// <summary>Armoured material of a plausible shape, and deliberately not a usable key.</summary>
    private static string PrivateKey(string body) =>
        $"-----BEGIN OPENSSH PRIVATE KEY-----\n{body}\n-----END OPENSSH PRIVATE KEY-----\n";

    private Task<IVaultServer> SignInAsync(Uri serverUrl, CancellationToken cancellationToken)
    {
        // Counted so a test can assert that a rejected URL never got this far. Reaching here means a
        // browser would have opened in the real application.
        signInAttempts++;

        return server.SignInFailure is { } failure
            ? Task.FromException<IVaultServer>(failure)
            : Task.FromResult<IVaultServer>(server);
    }

    /// <remarks>
    /// Counted and recorded, and never a browser: resuming is the path that must reach the token endpoint
    /// and nothing else. <see cref="resumeFailure"/> stands in for a provider that refuses.
    /// </remarks>
    private Task<IVaultServer> ResumeAsync(
        Uri serverUrl,
        string refreshToken,
        CancellationToken cancellationToken)
    {
        resumeAttempts++;
        resumedWith = refreshToken;

        return resumeFailure is { } failure
            ? Task.FromException<IVaultServer>(failure)
            : Task.FromResult<IVaultServer>(server);
    }

    /// <summary>
    /// A second shell over the same profile directory, as a relaunch of the application is.
    /// </summary>
    /// <remarks>
    /// Its sign-in delegate throws by default, which is the assertion rather than a convenience: a launch
    /// that reached it would be one that opened a browser at somebody, and every test using this is about
    /// a launch that must not.
    /// </remarks>
    private MainWindowViewModel Relaunch(
        IDeviceKeyStore? keys = null,
        MainWindowViewModel.ResumeHandler? resume = null) =>
        new(
            paths,
            caches,
            workspace,
            new VaultKnownHostStore(),
            keys ?? new UnavailableDeviceKeyStore(),
            (_, _) => throw new InvalidOperationException("The shell opened a browser on launch."),
            TimeProvider.System,
            ssh,
            CheapProfile,
            resume);

    private async Task SignedInAsync()
    {
        await shell.StartAsync(Token);
        await shell.SignInCommand.ExecuteAsync(null);
        shell.State.ShouldBe(ShellState.NeedsEnrollment);
    }

    private async Task EnrolledAsync()
    {
        await SignedInAsync();

        shell.Passphrase = Passphrase;
        shell.ConfirmPassphrase = Passphrase;

        await shell.EnrollCommand.ExecuteAsync(null);
    }

    private async Task EnrolledAndConfirmedAsync()
    {
        await EnrolledAsync();

        shell.RecoveryCodeWrittenDown = true;
        shell.ConfirmRecoveryCodeCommand.Execute(null);

        shell.State.ShouldBe(ShellState.Locked);
    }

    private Task ReadyToUnlockAsync() => EnrolledAndConfirmedAsync();

    // ---- Unlocking with this machine's device key ----

    [Fact]
    public async Task AnUnlockedVaultOffersToRegisterThisMachine()
    {
        await UnlockedAsync();

        shell.CanRegisterDevice.ShouldBeTrue();

        // Not before: a locked machine with nothing registered has nothing to offer, and the unlock screen
        // must not show a gesture button for a key it does not have.
        shell.CanUnlockWithDevice.ShouldBeFalse();
    }

    /// <remarks>
    /// The phone's case, tested here because the seam is the shell's. <c>Environment.MachineName</c> answers
    /// <c>localhost</c> on Android, so a head with no way to say what it is called would put one
    /// indistinguishable row per phone into the account's device list — which is the list somebody revokes a
    /// lost handset from, and a row nobody can identify is a revocation nobody dares press. See
    /// docs/android-port.md §7.
    /// </remarks>
    [Fact]
    public async Task AHeadThatKnowsWhatThisDeviceIsCalled_RegistersItUnderThatName()
    {
        // An enrolled account with a keychain on it, exactly as a phone signing in to an existing account
        // finds. The registering shell below is a second one over the same profile, which is what every
        // other "another launch" test in this file does.
        await UnlockedAsync();
        await shell.LockCommand.ExecuteAsync(null);

        var phone = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            new VaultKnownHostStore(),
            deviceKeys,
            SignInAsync,
            TimeProvider.System,
            ssh,
            CheapProfile,
            ResumeAsync,
            deviceName: "Jaap's Pixel");

        await using var _ = phone.ConfigureAwait(false);

        await phone.StartAsync(Token);
        await phone.SignInCommand.ExecuteAsync(null);

        phone.Passphrase = Passphrase;
        await phone.UnlockCommand.ExecuteAsync(null);
        phone.State.ShouldBe(ShellState.Unlocked, phone.StatusMessage);

        await phone.RegisterDeviceCommand.ExecuteAsync(null);

        server.RegisteredDeviceNames.ShouldBe(["Jaap's Pixel"]);

        // And it is said back, because "this phone can now unlock without your passphrase" is a sentence
        // about one device out of several.
        phone.StatusMessage.ShouldContain("Jaap's Pixel");
    }

    [Fact]
    public async Task AHeadThatSaysNothing_RegistersUnderThisMachinesOwnName()
    {
        // The desktop, and the reason the parameter is optional: a head running on an operating system whose
        // machine name is real passes nothing and gets it.
        await UnlockedAsync();
        await shell.RegisterDeviceCommand.ExecuteAsync(null);

        server.RegisteredDeviceNames.ShouldBe([Environment.MachineName]);
    }

    [Fact]
    public async Task RegisteringThenRelaunching_UnlocksWithTheGestureAndNoPassphrase()
    {
        // The shell's half of the feature, end to end through the commands a user actually presses.
        await UnlockedAsync();
        await shell.RegisterDeviceCommand.ExecuteAsync(null);

        shell.CanRegisterDevice.ShouldBeFalse("it is registered now, so the offer is spent");
        server.RegisteredDevices.Count.ShouldBe(1);

        await shell.LockCommand.ExecuteAsync(null);
        await shell.StartAsync(Token);

        shell.CanUnlockWithDevice.ShouldBeTrue("the wrap is cached and the keystore still has the key");

        await shell.UnlockWithDeviceCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
        shell.Passphrase.ShouldBeEmpty("nothing was typed");
    }

    [Fact]
    public async Task WithdrawingTheDevice_SendsThisMachineBackToThePassphraseAndClearsTheAccount()
    {
        // The button that makes revocation reachable at all. Until it existed, ForgetDeviceAsync had one
        // caller and that caller was a test.
        await UnlockedAsync();
        await shell.RegisterDeviceCommand.ExecuteAsync(null);

        shell.CanForgetDevice.ShouldBeTrue("there is a device key here now");

        await shell.ForgetDeviceCommand.ExecuteAsync(null);

        shell.CanForgetDevice.ShouldBeFalse("the offer is spent");
        server.RegisteredDevices.ShouldBeEmpty("the account must not go on listing it");

        await shell.LockCommand.ExecuteAsync(null);
        await shell.StartAsync(Token);

        shell.CanUnlockWithDevice.ShouldBeFalse("there is nothing left to unlock with");

        // And the passphrase still opens it, which is what makes withdrawing safe to offer without a
        // confirmation prompt.
        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
    }

    /// <remarks>
    /// The offer and the withdrawal are two flags rather than one and its negation, and this is why: on a
    /// machine with no keystore both are false, and a single flag would have made "cannot register" mean
    /// "has something to withdraw".
    /// </remarks>
    [Fact]
    public async Task OnAMachineWithNoKeystore_ThereIsNothingToWithdrawEither()
    {
        deviceKeys.IsAvailable = false;

        await UnlockedAsync();

        shell.CanRegisterDevice.ShouldBeFalse();
        shell.CanForgetDevice.ShouldBeFalse();
    }

    [Fact]
    public async Task WithdrawingTheDeviceOffline_StopsThisMachineAndSaysTheAccountStillListsIt()
    {
        // Somebody who has just realised a machine is in the wrong hands may well be on a train. Refusing
        // until they are online would leave the device unlocking itself for the whole journey.
        await UnlockedAsync();
        await shell.RegisterDeviceCommand.ExecuteAsync(null);
        await shell.LockCommand.ExecuteAsync(null);

        // The same keystore, so this machine still holds its device key — only the network is gone.
        var offline = new MainWindowViewModel(
            paths,
            caches,
            workspace,
            new VaultKnownHostStore(),
            deviceKeys,
            (_, _) => throw new InvalidOperationException("The shell went to the network."),
            TimeProvider.System,
            ssh,
            CheapProfile);

        await using var _ = offline.ConfigureAwait(false);

        await offline.StartAsync(Token);
        offline.IsOnline.ShouldBeFalse();

        offline.Passphrase = Passphrase;
        await offline.UnlockCommand.ExecuteAsync(null);
        offline.State.ShouldBe(ShellState.Unlocked, offline.StatusMessage);
        offline.CanForgetDevice.ShouldBeTrue();

        await offline.ForgetDeviceCommand.ExecuteAsync(null);

        offline.StatusMessage.ShouldContain("still lists it");
        offline.CanForgetDevice.ShouldBeFalse();
        server.RegisteredDevices.Count.ShouldBe(1, "nothing reached the server, and it must not pretend");

        // The half that decides whether this machine may let itself in happened anyway.
        await offline.LockCommand.ExecuteAsync(null);
        await offline.StartAsync(Token);

        offline.CanUnlockWithDevice.ShouldBeFalse();
    }

    [Fact]
    public async Task OnAMachineWithNoKeystore_NeitherAffordanceAppears()
    {
        deviceKeys.IsAvailable = false;

        await UnlockedAsync();

        shell.CanRegisterDevice.ShouldBeFalse();
        shell.CanUnlockWithDevice.ShouldBeFalse();
    }

    [Fact]
    public async Task ADeclinedGesture_LeavesTheUnlockScreenUsable()
    {
        // The fallback that makes the whole thing safe to offer: a cancelled prompt changes the message and
        // nothing else, and the passphrase still opens the vault.
        await UnlockedAsync();
        await shell.RegisterDeviceCommand.ExecuteAsync(null);
        await shell.LockCommand.ExecuteAsync(null);
        await shell.StartAsync(Token);

        deviceKeys.Decline = true;
        await shell.UnlockWithDeviceCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Locked);

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
    }

    // ---- Staying signed in, and syncing on its own ----

    /// <remarks>
    /// The behaviour the whole remembered-sign-in mechanism exists for. Before it, a machine that had been
    /// set up launched <em>offline</em> and stayed there until somebody found the SIGN IN button on the
    /// preferences screen — so the sync loop ran once a minute against nothing, and a colleague's change
    /// arrived when a user happened to go looking for it.
    /// </remarks>
    [Fact]
    public async Task ARelaunchComesBackOnlineWithoutOpeningABrowser()
    {
        await UnlockedAsync();

        // The pass that remembers the sign-in. It is the one the loop runs when the vault opens; driven
        // here rather than raced against.
        await shell.Vault!.SyncOnOpenAsync(Token);

        await shell.LockCommand.ExecuteAsync(null);

        var relaunch = Relaunch(resume: ResumeAsync);
        await using var _ = relaunch.ConfigureAwait(false);

        await relaunch.StartAsync(Token);

        relaunch.State.ShouldBe(ShellState.Locked);
        relaunch.IsOnline.ShouldBeFalse(
            "the token is sealed under the vault's key, so a locked machine cannot reach the server");
        resumeAttempts.ShouldBe(0);

        relaunch.Passphrase = Passphrase;
        await relaunch.UnlockCommand.ExecuteAsync(null);

        await relaunch.Vault!.SyncOnOpenAsync(Token);

        relaunch.IsOnline.ShouldBeTrue();
        resumedWith.ShouldBe("refresh-token-1");
        signInAttempts.ShouldBe(1, "the browser opened once, at setup, and must not open again");
    }

    /// <remarks>
    /// Providers rotate refresh tokens on use, and a client that persisted only the first one it saw would
    /// present a retired token on the next launch and be signed out for no visible reason. This is the one
    /// failure in the mechanism that would look like flakiness rather than a bug.
    /// </remarks>
    [Fact]
    public async Task ARotatedTokenIsTheOneTheNextLaunchPresents()
    {
        await UnlockedAsync();
        await shell.Vault!.SyncOnOpenAsync(Token);

        server.RefreshToken = "refresh-token-2";
        await shell.Vault.SyncOnOpenAsync(Token);

        await shell.LockCommand.ExecuteAsync(null);

        var relaunch = Relaunch(resume: ResumeAsync);
        await using var _ = relaunch.ConfigureAwait(false);

        await relaunch.StartAsync(Token);
        relaunch.Passphrase = Passphrase;
        await relaunch.UnlockCommand.ExecuteAsync(null);

        await relaunch.Vault!.SyncOnOpenAsync(Token);

        resumedWith.ShouldBe("refresh-token-2");
    }

    [Fact]
    public async Task ARefusedSignIn_IsSaidOnceAndNotRetriedForever()
    {
        await UnlockedAsync();
        await shell.Vault!.SyncOnOpenAsync(Token);
        await shell.LockCommand.ExecuteAsync(null);

        // What a revoked session, or a rotation this machine missed, looks like from the token endpoint.
        resumeFailure = new OidcException(
            "The token endpoint returned 400: Invalid refresh token.", "invalid_grant");

        var relaunch = Relaunch(resume: ResumeAsync);
        await using var _ = relaunch.ConfigureAwait(false);

        await relaunch.StartAsync(Token);
        relaunch.Passphrase = Passphrase;
        await relaunch.UnlockCommand.ExecuteAsync(null);

        // The vault opens regardless: nothing about being signed out stops a passphrase working.
        relaunch.State.ShouldBe(ShellState.Unlocked, relaunch.StatusMessage);

        await relaunch.Vault!.SyncOnOpenAsync(Token);

        relaunch.IsOnline.ShouldBeFalse();
        relaunch.Vault.Status.ShouldContain("expired", Case.Insensitive);

        var attempted = resumeAttempts;
        attempted.ShouldBeGreaterThan(0);

        // And the token is dropped rather than retried once a minute for the life of the profile.
        await relaunch.Vault.SyncOnOpenAsync(Token);

        resumeAttempts.ShouldBe(attempted);
    }

    /// <remarks>
    /// The pass that runs when the vault opens used to be skipped in the application and nowhere else: the
    /// loop is started from inside the unlock command, so the busy flag it yields to was raised by the
    /// unlock itself. It cost a full minute of a machine that was online and out of date, and no test saw
    /// it because every test called the pass by hand with nothing busy.
    /// </remarks>
    [Fact]
    public async Task ThePassOnOpen_RunsEvenThoughUnlockingIsStillBusy()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        server.SyncFailure = new HttpRequestException("The server is having a bad day.");
        await AddHostAsync(vault, "prod-db");
        server.SyncFailure = null;

        vault.PendingChanges.ShouldBe(1, "there must be something to push for this to mean anything");

        // Standing in for the unlock command that is still running when the loop starts its first pass.
        vault.IsBusy = true;

        await vault.SyncOnOpenAsync(Token);

        vault.PendingChanges.ShouldBe(0, "the pass on open does not yield to the unlock that started it");
        server.LiveRowCount.ShouldBe(1);

        vault.IsBusy = false;
    }

    // ---- Signing out ----

    [Fact]
    public async Task SigningOutIsAQuestionFirst()
    {
        await UnlockedAsync();

        shell.SignOutCommand.Execute(null);

        shell.IsConfirmingSignOut.ShouldBeTrue();
        shell.IsAskingForThePassphrase.ShouldBeFalse("the two cards swap rather than stack");
        shell.State.ShouldBe(ShellState.Unlocked, "arming the question changes nothing else");
        shell.Vault.ShouldNotBeNull();

        shell.CancelSignOutCommand.Execute(null);

        shell.IsConfirmingSignOut.ShouldBeFalse();
        shell.State.ShouldBe(ShellState.Unlocked);
        shell.Vault.ShouldNotBeNull("cancelling must not have closed anything");
    }

    [Fact]
    public async Task SigningOut_DeletesThisMachinesCopyAndLeavesTheVaultOnTheServer()
    {
        await UnlockedAsync();
        await AddHostAsync(shell.Vault!, "prod-db");

        server.LiveRowCount.ShouldBe(1);

        shell.SignOutCommand.Execute(null);
        await shell.ConfirmSignOutCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.NeedsServer);
        shell.Vault.ShouldBeNull("the vault's keys are gone");
        shell.IsOnline.ShouldBeFalse("and so is the connection");
        shell.AccountName.ShouldBeNull();
        shell.IsConfirmingSignOut.ShouldBeFalse();

        server.LiveRowCount.ShouldBe(1, "the vault lives on the server and signing out does not touch it");

        // A relaunch finds a machine that has never been set up, which is what "reset" has to mean.
        var relaunch = Relaunch();
        await using var _ = relaunch.ConfigureAwait(false);

        await relaunch.StartAsync(Token);

        relaunch.State.ShouldBe(ShellState.NeedsServer);
        relaunch.AccountName.ShouldBeNull();
    }

    /// <remarks>
    /// The half that makes signing out a reset rather than a wipe: the cache is emptied and immediately
    /// usable, so setting the machine up again needs no restart. It is also the way back for somebody who
    /// has forgotten their passphrase, which is why the button is on the unlock screen too.
    /// </remarks>
    [Fact]
    public async Task AfterSigningOut_TheSameApplicationCanBeSetUpAgain()
    {
        await UnlockedAsync();

        shell.SignOutCommand.Execute(null);
        await shell.ConfirmSignOutCommand.ExecuteAsync(null);

        await shell.SignInCommand.ExecuteAsync(null);

        // The account is already enrolled — this machine forgot it, the server did not — so the wrap and
        // the salt are cached again from /me and the old passphrase still opens them.
        shell.State.ShouldBe(ShellState.Locked, shell.StatusMessage);

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
        shell.Vault.ShouldNotBeNull();
    }

    [Fact]
    public async Task SigningOutWithQueuedChanges_SaysHowManyWillBeLost()
    {
        await UnlockedAsync();
        var vault = shell.Vault!;

        // A change this machine made and could not send is the one thing signing out destroys that
        // nothing else has a copy of, so the count is the whole point of the confirmation.
        server.SyncFailure = new HttpRequestException("The server is having a bad day.");
        await AddHostAsync(vault, "prod-db");

        vault.PendingChanges.ShouldBe(1);

        shell.SignOutCommand.Execute(null);

        shell.SignOutWarning.ShouldContain("1 change");
        shell.SignOutWarning.ShouldContain("lost");
    }

    [Fact]
    public async Task SigningOutWhileLocked_AdmitsItCannotCountWhatWouldBeLost()
    {
        await UnlockedAsync();
        await shell.LockCommand.ExecuteAsync(null);

        shell.SignOutCommand.Execute(null);

        // The outbox is sealed under the key the vault holds, so a locked machine genuinely cannot count
        // it. Saying "nothing will be lost" here would be a claim this state cannot support.
        shell.SignOutWarning.ShouldContain("cannot be counted");
    }

    [Fact]
    public async Task SigningOut_WithdrawsThisMachineFromTheAccount()
    {
        // The leftover ADR 0007 is about: a device wrap on the account whose private half has just been
        // deleted is one nobody can account for and nothing can use.
        await UnlockedAsync();
        await shell.RegisterDeviceCommand.ExecuteAsync(null);

        server.RegisteredDevices.Count.ShouldBe(1);

        shell.SignOutCommand.Execute(null);
        await shell.ConfirmSignOutCommand.ExecuteAsync(null);

        server.RegisteredDevices.ShouldBeEmpty();
        deviceKeys.Peek().ShouldBeNull("this machine's own copy of the key goes too");

        var relaunch = Relaunch(keys: deviceKeys);
        await using var _ = relaunch.ConfigureAwait(false);

        await relaunch.StartAsync(Token);

        relaunch.CanUnlockWithDevice.ShouldBeFalse();
    }

    /// <remarks>
    /// Signing out is the strongest thing this application does to itself, and it deliberately does not do
    /// the one thing locking refuses to do either. The argument is the same one <c>LockAsync</c> carries:
    /// a session that authenticated before is still running somebody's job, and a button that destroyed it
    /// would be a button people stop pressing.
    /// </remarks>
    [Fact]
    public async Task SigningOut_LeavesOpenShellsRunningAndSaysSo()
    {
        await UnlockedAsync();

        await workspace.OpenSessionAsync(
            new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
            TerminalSize.Default,
            progress: null,
            Token);

        shell.SignOutCommand.Execute(null);

        shell.HasLiveSessions.ShouldBeTrue();
        shell.LiveSessionSummary.ShouldBe("1 shell is still connected and still running.");

        await shell.ConfirmSignOutCommand.ExecuteAsync(null);

        workspace.LiveSessionCount.ShouldBe(1);
        shell.State.ShouldBe(ShellState.NeedsServer);
    }

    // ---- File transfer ----

    /// <remarks>
    /// The list is followed rather than copied at unlock, which is the whole of this test. A snapshot taken
    /// when the vault opened meant a host created five seconds later could not be picked here until the
    /// keychain had been locked and opened again — and nothing on the screen explained why the machine that
    /// was plainly in the host list was missing from the picker.
    /// </remarks>
    [Fact]
    public async Task TheTransfersScreen_FollowsTheVaultsHostList()
    {
        await UnlockedAsync();

        shell.Transfers.Hosts.ShouldBeEmpty("the vault has no hosts yet");

        await AddHostAsync(shell.Vault!, "prod-db");

        shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
        shell.Transfers.SelectedHost?.Label.ShouldBe("prod-db", "the only host is the one to offer");

        await shell.LockCommand.ExecuteAsync(null);

        shell.Transfers.Hosts.ShouldBeEmpty("those rows carry decrypted secrets");

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
    }

    /// <remarks>
    /// The lock policy, applied to the other thing that can be in flight. <c>LockAsync</c> argues that
    /// locking must not destroy work — it is what somebody does when they walk away from the machine, which
    /// is exactly when a long transfer is most likely to be running. A screen rebuilt per unlock would have
    /// dropped the session and with it whatever was moving.
    /// </remarks>
    [Fact]
    public async Task Locking_LeavesAFileTransferConnectionOpenAndOnlyTakesTheHostListAway()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;
        await AddHostAsync(vault, "prod-db");

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);

        await shell.LockCommand.ExecuteAsync(null);

        shell.Transfers.IsConnected.ShouldBeTrue("locking the vault is not a disconnect");

        // What it does take is the host list, and it has to: those rows carry decrypted secrets and the
        // vault they came from has just been disposed.
        shell.Transfers.Hosts.ShouldBeEmpty();
        shell.Transfers.SelectedHost.ShouldBeNull();
    }

    /// <remarks>
    /// The consequence of SSH.NET having no way to open an SFTP subsystem on an existing transport, made
    /// visible: browsing a host's files authenticates again rather than reusing the terminal's connection.
    /// It is asserted rather than merely written down because the host's audit log shows a second login,
    /// and somebody will eventually be asked to explain it.
    /// </remarks>
    [Fact]
    public async Task ConnectingTheTransfersScreen_OpensItsOwnConnectionRatherThanReusingATerminals()
    {
        var vault = await ReadyToConnectAsync();

        await ConnectWithRendererAsync(vault);

        ssh.Requests.Count.ShouldBe(1);
        ssh.SftpRequests.ShouldBeEmpty("opening a terminal must not open a file-transfer session");

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        ssh.SftpRequests.Count.ShouldBe(1);
        ssh.Requests.Count.ShouldBe(1, "and it must not open a shell either");

        // It opens on the account's home directory, which is the only path the layer knows without asking.
        shell.Transfers.RemotePath.ShouldBe("/home/deploy");
        shell.Transfers.RemoteEntries.Select(entry => entry.Name).ShouldBe(["notes.txt"]);
    }

    /// <remarks>
    /// The phone's Files-screen chip row, proven at the view model rather than through Avalonia: a pin
    /// saved on the host before this screen ever connects to it is read straight off the row's own
    /// <c>HostSecret.PinnedPaths</c> at the moment <c>MarkHostConnected</c> runs, which is what
    /// <see cref="TransfersViewModel.ConnectedPinnedPaths"/>'s own remark promises rather than a live follow
    /// of the vault.
    /// </remarks>
    [Fact]
    public async Task ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths()
    {
        var vault = await ReadyToConnectAsync();

        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
        shell.Transfers.ConnectedPinnedPaths.ShouldBe(["/var/www/app"]);
        shell.Transfers.HasConnectedPins.ShouldBeTrue();
    }

    /// <remarks>
    /// The other half of <see cref="ConnectingATransfersHostWithPins_PopulatesConnectedPinnedPaths"/>: the
    /// chip row has to go with the connection it belongs to, or a later connect to a host with no pins would
    /// show the previous host's.
    /// </remarks>
    [Fact]
    public async Task DisconnectingTheTransfersScreen_ClearsConnectedPinnedPaths()
    {
        var vault = await ReadyToConnectAsync();

        vault.EditSelectedHostCommand.Execute(null);
        vault.EditorNewPin = "/var/www/app";
        vault.AddEditorPinCommand.Execute(null);
        await vault.SaveHostCommand.ExecuteAsync(null);

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);
        shell.Transfers.HasConnectedPins.ShouldBeTrue();

        await shell.Transfers.DisconnectCommand.ExecuteAsync(null);

        shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
        shell.Transfers.HasConnectedPins.ShouldBeFalse();
    }

    /// <remarks>
    /// The phone's foreground-service question, proven at the view model rather than through Android: a
    /// connect that opens an SFTP session is exactly the transition <c>SessionKeepAlive</c> needs to hear
    /// about even when no transfer ever moves — see <see cref="TransfersViewModel.ActivityChanged"/>'s own
    /// remark for why the queue's own raise, in <c>OnTransferChanged</c>, cannot cover a connect that never
    /// touches <c>Transfers</c> at all.
    /// </remarks>
    [Fact]
    public async Task ConnectingATransfersHost_RaisesActivityChangedAndTurnsOnHasLiveFileSession()
    {
        var vault = await ReadyToConnectAsync();

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        var raised = 0;
        shell.Transfers.ActivityChanged += (_, _) => raised++;

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
        shell.Transfers.HasLiveFileSession.ShouldBeTrue();
        raised.ShouldBeGreaterThan(0);
    }

    /// <remarks>
    /// The other half: a disconnect is as much a transition the service must hear about as a connect is,
    /// because it is the moment the connection <see cref="TransfersViewModel.HasLiveFileSession"/> promised
    /// was open stops being true — and the foreground service would otherwise keep the process alive over a
    /// session that has already closed.
    /// </remarks>
    [Fact]
    public async Task DisconnectingTheTransfersScreen_RaisesActivityChangedAndTurnsOffHasLiveFileSession()
    {
        var vault = await ReadyToConnectAsync();

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);
        shell.Transfers.HasLiveFileSession.ShouldBeTrue();

        var raised = 0;
        shell.Transfers.ActivityChanged += (_, _) => raised++;

        await shell.Transfers.DisconnectCommand.ExecuteAsync(null);

        shell.Transfers.HasLiveFileSession.ShouldBeFalse();
        raised.ShouldBeGreaterThan(0);
    }

    /// <remarks>
    /// A bucket is an <c>IRemoteFileStore</c> with no <c>HostSecret</c> underneath it, so there is no
    /// <c>PinnedPaths</c> to read at all — see <see cref="TransfersViewModel.OpenBucketAsync"/>'s own remark.
    /// The bucket here is created through the same keychain route
    /// <see cref="TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere"/> exercises, and
    /// <see cref="FakeObjectStoreFactory"/> stands in for the network the way <see cref="FakeSshConnectionFactory"/>
    /// already does for SFTP.
    /// </remarks>
    [Fact]
    public async Task ConnectingABucket_LeavesConnectedPinnedPathsEmpty()
    {
        var vault = await ReadyToConnectAsync();

        shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());

        vault.NewObjectStoreCommand.Execute(null);
        vault.BucketEditorLabel = "Backups";
        vault.BucketEditorBucket = "backups";
        vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
        vault.BucketEditorSecretAccessKey = "a-secret-access-key";
        vault.BucketEditorRegion = "eu-west-1";
        await vault.SaveObjectStoreCommand.ExecuteAsync(null);

        // Remote is what ConnectAsync branches on, and Attach's RefreshHosts has already auto-selected the
        // host ReadyToConnectAsync left in the picker — without this line the command below dialled that
        // host, and every assertion here passed only because that host happens to have no pins either. The
        // ConnectedTo check is the proof the bucket path was actually taken.
        shell.Transfers.Remote = RemoteKind.Bucket;
        shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        shell.Transfers.ConnectedTo.ShouldBe("s3://backups");
        shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
        shell.Transfers.ConnectedPinnedPaths.ShouldBeEmpty();
        shell.Transfers.HasConnectedPins.ShouldBeFalse();
    }

    /// <remarks>
    /// A bucket is HTTP, per-request, with nothing open that a dying process would lose — see
    /// <see cref="TransfersViewModel.HasLiveFileSession"/>'s own remark. <c>IsConnected</c> alone would have
    /// answered this wrongly, which is exactly why the flag reads <c>ConnectedCipher</c> as well: nothing
    /// underneath a bucket ever sets it.
    /// </remarks>
    [Fact]
    public async Task ConnectingABucket_LeavesHasLiveFileSessionOff()
    {
        var vault = await ReadyToConnectAsync();

        shell.Transfers.Attach(vault, knownHosts, buckets: new FakeObjectStoreFactory());

        vault.NewObjectStoreCommand.Execute(null);
        vault.BucketEditorLabel = "Backups";
        vault.BucketEditorBucket = "backups";
        vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
        vault.BucketEditorSecretAccessKey = "a-secret-access-key";
        vault.BucketEditorRegion = "eu-west-1";
        await vault.SaveObjectStoreCommand.ExecuteAsync(null);

        // ReadyToConnectAsync already left a host in the picker, and Attach's own RefreshHosts auto-selects
        // it — so without this the CONNECT command below would dial that host rather than open the bucket,
        // and a host with no pins would make ConnectedPinnedPathsEmpty-style assertions pass for the wrong
        // reason. Remote is what ConnectAsync actually branches on.
        shell.Transfers.Remote = RemoteKind.Bucket;
        shell.Transfers.SelectedBucket = shell.Transfers.Buckets[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        shell.Transfers.ConnectedTo.ShouldBe("s3://backups", "proof this opened the bucket rather than the host");
        shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
        shell.Transfers.HasLiveFileSession.ShouldBeFalse();
    }

    /// <summary>A bucket that opens and lists as empty, so a bucket connect can be proven with no network.</summary>
    private sealed class FakeObjectStoreFactory : IObjectStoreFactory
    {
        public IRemoteFileStore Open(ObjectStoreSecret store) => new FakeBucketStore();
    }

    /// <summary>The minimum <see cref="IRemoteFileStore"/> a bucket connect touches: home, then a listing.</summary>
    private sealed class FakeBucketStore : IRemoteFileStore
    {
        public bool IsConnected => true;

        public string HomeDirectory => "/";

        public Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken) =>
            Task.FromResult<IReadOnlyList<SftpEntry>>([]);

        public Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken) =>
            Task.FromResult<SftpEntry?>(null);

        public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
            throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");

        public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
            throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");

        public Task CreateDirectoryAsync(string path, CancellationToken cancellationToken) =>
            throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");

        public Task DeleteAsync(string path, CancellationToken cancellationToken) =>
            throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");

        public Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken) =>
            throw new NotSupportedException("Not exercised by proving a bucket connect leaves no pins.");

        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
    }

    /// <remarks>
    /// <para>
    /// The picker that replaced the desktop's connect bar, and the four things that put it away again. It
    /// is a flag rather than a screen, so what is worth asserting is that it is never left open over a pane
    /// it no longer belongs to: connecting closes it, disconnecting closes it, moving between SFTP and S3
    /// closes it, and losing the vault closes it.
    /// </para>
    /// <para>
    /// The last two are the ones that would rot quietly. A picker surviving a hop to the other tab offers
    /// hosts on a screen showing buckets, and one surviving a lock offers a list that has just been emptied
    /// because its rows carried decrypted secrets.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheFilePickerIsPutAwayByEverythingThatChangesWhatItWouldBePicking()
    {
        var vault = await ReadyToConnectAsync();

        shell.Transfers.Attach(vault, knownHosts);
        shell.Transfers.IsChoosingRemote.ShouldBeFalse("the pane opens on its invitation");

        // Connecting.
        shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
        shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];

        await shell.Transfers.ConnectCommand.ExecuteAsync(null);

        shell.Transfers.IsConnected.ShouldBeTrue(shell.Transfers.Status);
        shell.Transfers.IsChoosingRemote.ShouldBeFalse("what is open is the listing now");

        // Disconnecting, which puts the pane back to the invitation rather than to the form.
        await shell.Transfers.DisconnectCommand.ExecuteAsync(null);

        shell.Transfers.IsConnected.ShouldBeFalse(shell.Transfers.Status);
        shell.Transfers.IsChoosingRemote.ShouldBeFalse();

        // Moving to the other destination, which has a different list behind it.
        shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
        shell.ShowFilesCommand.Execute(RemoteKind.Bucket);

        shell.Transfers.ShowsBucketPicker.ShouldBeTrue();
        shell.Transfers.IsChoosingRemote.ShouldBeFalse("that picker was offering hosts");

        // Cancelling, which also takes the typed password with it.
        shell.Transfers.BeginChoosingRemoteCommand.Execute(null);
        shell.Transfers.TypedPassword = "hunter2";

        shell.Transfers.CancelChoosingRemoteCommand.Execute(null);

        shell.Transfers.IsChoosingRemote.ShouldBeFalse();
        shell.Transfers.TypedPassword.ShouldBeEmpty("a secret nobody asked to keep");

        // And losing the vault, which empties both lists.
        shell.Transfers.BeginChoosingRemoteCommand.Execute(null);

        await shell.LockCommand.ExecuteAsync(null);

        shell.Transfers.Hosts.ShouldBeEmpty();
        shell.Transfers.IsChoosingRemote.ShouldBeFalse("a form offering a choice between nothing");
    }

    /// <remarks>
    /// <para>
    /// The S3 screen with nothing in the keychain, which is where every new account starts and which used to
    /// be a dead end: SELECT BUCKET opened a combo box with nothing in it, and nothing anywhere said that a
    /// bucket is made on the keychain screen. Somebody standing here had arrived at the place a bucket is
    /// used and been shown no route to the place one is created — which is indistinguishable from an
    /// application that cannot add one at all.
    /// </para>
    /// <para>
    /// The assertion that matters is the last pair: pressing the button lands on the keychain <em>with the
    /// editor already open</em>. Navigating to the screen and leaving the user to find + BUCKET among five
    /// buttons would be most of the same problem.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheS3ScreenWithNoBuckets_SaysWhereOneIsMadeAndGoesThere()
    {
        var vault = await ReadyToConnectAsync();

        shell.Transfers.Attach(vault, knownHosts);
        shell.ShowFilesCommand.Execute(RemoteKind.Bucket);

        shell.Transfers.ShowsNoBuckets.ShouldBeTrue("nothing has been added to the keychain");
        shell.Transfers.ShowsBucketChoice.ShouldBeFalse("there is nothing to choose between");

        shell.Transfers.AddBucketCommand.Execute(null);

        shell.Screen.ShouldBe(ShellScreen.Keychain);
        vault.IsEditingObjectStore.ShouldBeTrue("the route has to land on the editor, not near it");

        vault.BucketEditorLabel = "Backups";
        vault.BucketEditorBucket = "backups";
        vault.BucketEditorAccessKeyId = "AKIAEXAMPLE";
        vault.BucketEditorSecretAccessKey = "a-secret-access-key";
        vault.BucketEditorRegion = "eu-west-1";

        await vault.SaveObjectStoreCommand.ExecuteAsync(null);

        shell.ShowFilesCommand.Execute(RemoteKind.Bucket);

        shell.Transfers.ShowsNoBuckets.ShouldBeFalse(vault.Status);
        shell.Transfers.ShowsBucketChoice.ShouldBeTrue("the bucket that was just made is the one to open");
    }

    private async Task UnlockedAsync()
    {
        await EnrolledAndConfirmedAsync();

        shell.Passphrase = Passphrase;
        await shell.UnlockCommand.ExecuteAsync(null);

        shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
    }

    private static async Task AddHostAsync(VaultViewModel vault, string label)
    {
        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = label;
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";

        await vault.SaveHostCommand.ExecuteAsync(null);
    }

    private static async Task AddKeyAsync(
        VaultViewModel vault,
        string label,
        string material = "MATERIAL",
        string passphrase = "hunter2")
    {
        vault.NewKeyCommand.Execute(null);
        vault.KeyEditorLabel = label;
        vault.KeyEditorPrivateKey = PrivateKey(material);
        vault.KeyEditorPassphrase = passphrase;

        await vault.SaveKeyCommand.ExecuteAsync(null);
    }

    /// <summary>The host row with a given name, which the list orders by label rather than by age.</summary>
    private static HostRowViewModel Host(VaultViewModel vault, string label) =>
        vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));

    /// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
    /// <remarks>
    /// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
    /// which is what <c>DeletingAHost_AsksFirstAndChangesNothingUntilItIsAnswered</c> holds it to. Tests
    /// about something else go through these three helpers, so the two-step is spelled out in one place
    /// rather than in ten.
    /// </remarks>
    private static async Task DeleteSelectedHostAsync(VaultViewModel vault)
    {
        vault.DeleteHostCommand.Execute(null);
        await vault.ConfirmDeleteCommand.ExecuteAsync(null);
    }

    /// <inheritdoc cref="DeleteSelectedHostAsync" />
    private static async Task DeleteSelectedKeyAsync(VaultViewModel vault)
    {
        vault.DeleteKeyCommand.Execute(null);
        await vault.ConfirmDeleteCommand.ExecuteAsync(null);
    }

    /// <inheritdoc cref="DeleteSelectedHostAsync" />
    private static async Task DeleteSelectedCredentialAsync(VaultViewModel vault)
    {
        vault.DeleteCredentialCommand.Execute(null);
        await vault.ConfirmDeleteCommand.ExecuteAsync(null);
    }

    /// <summary>Points a host at a key through the editor, the way a user would.</summary>
    private static Task BindKeyAsync(VaultViewModel vault, HostRowViewModel host, Guid keyId) =>
        BindAsync(vault, host, AuthenticationKind.SshKey, keyId);

    /// <summary>Points a host at a stored credential through the same picker.</summary>
    private static Task BindCredentialAsync(VaultViewModel vault, HostRowViewModel host, Guid credentialId) =>
        BindAsync(vault, host, AuthenticationKind.Credential, credentialId);

    /// <remarks>
    /// One helper for both because there is one control for both, and a test that reached for the binding a
    /// different way than the interface does would stop covering the interface.
    /// </remarks>
    private static async Task BindAsync(
        VaultViewModel vault,
        HostRowViewModel host,
        AuthenticationKind kind,
        Guid entityId)
    {
        vault.SelectedHost = host;
        vault.EditSelectedHostCommand.Execute(null);

        vault.IsEditing.ShouldBeTrue("the host editor has to be open for the picker to be populated");
        vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
            .Single(choice => choice.Kind == kind && choice.EntityId == entityId);

        await vault.SaveHostCommand.ExecuteAsync(null);
    }

    private static async Task AddCredentialAsync(
        VaultViewModel vault,
        string label,
        string password = "s3cret",
        string username = "")
    {
        vault.NewCredentialCommand.Execute(null);
        vault.CredentialEditorLabel = label;
        vault.CredentialEditorPassword = password;
        vault.CredentialEditorUsername = username;

        await vault.SaveCredentialCommand.ExecuteAsync(null);
    }

    /// <summary>Connects with a renderer attached, which the data plane requires before a session opens.</summary>
    private async Task ConnectWithRendererAsync(VaultViewModel vault)
    {
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);

        vault.Status.ShouldContain("Connected", Case.Insensitive);
    }

    /// <summary>
    /// The same, for a connection that is expected to store its password.
    /// </summary>
    /// <remarks>
    /// Without the status assertion, and that is the whole reason it is separate. Remembering writes two
    /// items and then pushes them, exactly as saving a host does, so the pass repaints the line with its own
    /// count — leaving "Connected" true of what happened and false of what the line says. What the connection
    /// actually did is asserted on the vault, which is where it is durable.
    /// </remarks>
    private async Task ConnectAndRememberAsync(VaultViewModel vault)
    {
        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);

        await vault.ConnectCommand.ExecuteAsync(null);

        ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
    }

    // ---- v5c: settings mode ----
    //
    // The window-level mode that swaps the titlebar, the rail and the page area for settings mode's own —
    // see MainWindowViewModel.EnterSettings and design-notes/v5c-fidelity-notes.md. What is worth proving at
    // this level, with no Avalonia involved, is the state machine itself: entering and leaving preserves
    // wherever the user actually was, switching between settings pages does not forget it, and the two
    // pages that mirror an existing ShellScreen keep every binding written against that screen before this
    // mode existed.

    /// <remarks>
    /// The core promise of "Back to application": whatever screen a user was on survives a trip through
    /// settings mode untouched, however many pages they visit while they are there.
    /// </remarks>
    [Fact]
    public async Task EnteringAndLeavingSettingsMode_PreservesTheScreenItWasEnteredFrom()
    {
        await ReadyToConnectAsync();
        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);

        shell.EnterSettingsCommand.Execute(SettingsPage.General);
        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(SettingsPage.General);

        // Switching pages inside settings mode must not overwrite the remembered return screen with a
        // settings page of its own — see the remark on MainWindowViewModel.settingsReturnScreen.
        shell.EnterSettingsCommand.Execute(SettingsPage.Security);
        shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);

        shell.LeaveSettingsCommand.Execute(null);

        shell.IsSettingsMode.ShouldBeFalse();
        shell.ActiveSettingsPage.ShouldBeNull();
        shell.Screen.ShouldBe(ShellScreen.Keychain);
    }

    /// <remarks>
    /// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
    /// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
    /// to bring it back rather than leaving the user on a page they never asked for.
    /// </remarks>
    [Fact]
    public async Task EnteringSettingsModeFromATerminal_CollapsesItAndLeavingRestoresIt()
    {
        var vault = await ReadyToConnectAsync();

        await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
        await vault.ConnectCommand.ExecuteAsync(null);

        shell.IsTerminalSurface.ShouldBeTrue();

        shell.EnterSettingsCommand.Execute(SettingsPage.Security);

        shell.IsTerminalSurface.ShouldBeFalse("settings mode occupies the same rectangle a page does");
        shell.IsSettingsMode.ShouldBeTrue();

        shell.LeaveSettingsCommand.Execute(null);

        shell.IsTerminalSurface.ShouldBeTrue();
        shell.IsSettingsMode.ShouldBeFalse();
    }

    /// <remarks>
    /// v5c: <see cref="ShellScreen.Preferences"/> and <see cref="ShellScreen.Vaults"/> are settings pages
    /// now, so anything that still navigates to either — a test written before this wave, the phone's own
    /// hub — is redirected into settings mode on the matching page rather than landing on a screen the
    /// design retired. <see cref="MainWindowViewModel.Screen"/> is kept in step with the two so every
    /// existing binding written against either screen keeps its answer.
    /// <para>
    /// Two <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
    /// and <c>SettingsPage</c> are both <c>internal</c>, and a public theory method may not carry an
    /// internal type in its signature.
    /// </para>
    /// </remarks>
    [Fact]
    public void ShowingPreferences_EntersSettingsModeOnThePreferencesPage() =>
        ShowingAScreenEntersSettingsModeOn(ShellScreen.Preferences, SettingsPage.Preferences);

    [Fact]
    public void ShowingVaults_EntersSettingsModeOnTheVaultsPage() =>
        ShowingAScreenEntersSettingsModeOn(ShellScreen.Vaults, SettingsPage.Vaults);

    /// <remarks>
    /// v5c-2: Groups and Tags joined settings mode with no <see cref="ShellScreen"/> counterpart — managing
    /// either has never been its own screen before this wave — so there is no redirect to prove, only that
    /// <see cref="MainWindowViewModel.EnterSettingsCommand"/> reaches each directly.
    /// </remarks>
    [Fact]
    public void EnteringSettingsOnGroups_ShowsTheGroupsPage()
    {
        shell.EnterSettingsCommand.Execute(SettingsPage.Groups);

        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(SettingsPage.Groups);
        shell.IsSettingsGroupsPage.ShouldBeTrue();
    }

    [Fact]
    public void EnteringSettingsOnTags_ShowsTheTagsPage()
    {
        shell.EnterSettingsCommand.Execute(SettingsPage.Tags);

        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(SettingsPage.Tags);
        shell.IsSettingsTagsPage.ShouldBeTrue();
    }

    private void ShowingAScreenEntersSettingsModeOn(ShellScreen screen, SettingsPage page)
    {
        shell.ShowScreenCommand.Execute(screen);

        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(page);
        shell.Screen.ShouldBe(screen);
        shell.IsShowingPages.ShouldBeTrue();
    }

    /// <remarks>
    /// A caller that names an ordinary screen while settings mode is up is not asking to go back to
    /// wherever settings was entered from — it is asking for that screen, which wins over "Back to
    /// application" restoring anything.
    /// </remarks>
    [Fact]
    public void NavigatingToAnOrdinaryScreenWhileInSettingsMode_LeavesSettingsModeOutright()
    {
        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
        shell.EnterSettingsCommand.Execute(SettingsPage.Security);

        shell.ShowScreenCommand.Execute(ShellScreen.Hosts);

        shell.IsSettingsMode.ShouldBeFalse();
        shell.Screen.ShouldBe(ShellScreen.Hosts);
    }

    /// <remarks>
    /// The confirmation card moved from the old bare Preferences screen to the Account settings page — see
    /// <see cref="MainWindowViewModel.SignOutFromPopover"/> — and this is the one command both the rail's
    /// popover Logout row and settings mode's own bottom Logout row call, so there is exactly one place the
    /// card is armed from.
    /// </remarks>
    [Fact]
    public async Task SignOutFromPopover_EntersSettingsOnAccountAndArmsTheConfirmation()
    {
        await ReadyToConnectAsync();

        shell.SignOutFromPopoverCommand.Execute(null);

        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
        shell.IsConfirmingSignOut.ShouldBeTrue();
    }

    // ---- v5c-3: the importer, inside settings mode ----
    //
    // Import.dc.html draws the importer over the Preferences page, with SettingsNav still lit on
    // Preferences — so ActiveSettingsPage never actually leaves SettingsPage.Preferences; only
    // MainWindowViewModel.IsImportOpen and IsSettingsPreferencesContentShowing move. See ShowScreen's own
    // translation of ShellScreen.Import, which is the Preferences page's "OPEN IMPORTER" row and every other
    // caller that used to land on the old bare screen.

    [Fact]
    public void ShowingImport_OpensTheImporterOverThePreferencesPage()
    {
        shell.ShowScreenCommand.Execute(ShellScreen.Import);

        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences, "SettingsNav stays lit on Preferences");
        shell.IsSettingsPreferencesPage.ShouldBeTrue();
        shell.IsImportOpen.ShouldBeTrue();
        shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the importer is drawn over it, not beside it");
    }

    /// <remarks>The titlebar's own "Back to preferences": closes the importer without leaving settings mode.</remarks>
    [Fact]
    public void CloseImport_ReturnsToPreferencesWithoutLeavingSettingsMode()
    {
        shell.ShowScreenCommand.Execute(ShellScreen.Import);

        shell.CloseImportCommand.Execute(null);

        shell.IsSettingsMode.ShouldBeTrue();
        shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences);
        shell.IsImportOpen.ShouldBeFalse();
        shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
    }

    /// <remarks>The importer's own footer Cancel button, wired through ImportViewModel's onCancel delegate.</remarks>
    [Fact]
    public async Task TheImporterScreensCancelButton_ClosesItTheSameWayTheTitlebarDoes()
    {
        await UnlockedAsync();

        shell.ShowScreenCommand.Execute(ShellScreen.Import);
        shell.IsImportOpen.ShouldBeTrue();

        shell.ImportScreen!.CancelCommand.Execute(null);

        shell.IsSettingsMode.ShouldBeTrue("Cancel backs out to Preferences, not out of Settings altogether");
        shell.IsImportOpen.ShouldBeFalse();
    }

    /// <remarks>
    /// Naming a settings page — including Preferences again — while the importer is up is a request for that
    /// page, not for whatever was drawn over it last time. Covers the nav rail's own Preferences row as well
    /// as every other page.
    /// </remarks>
    [Fact]
    public void EnteringAnySettingsPageWhileImportIsOpen_ClosesTheImporter()
    {
        shell.ShowScreenCommand.Execute(ShellScreen.Import);
        shell.IsImportOpen.ShouldBeTrue();

        shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);

        shell.IsImportOpen.ShouldBeFalse();
        shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
    }

    [Fact]
    public void LeavingSettingsModeWhileImportIsOpen_ClosesTheImporterToo()
    {
        shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
        shell.ShowScreenCommand.Execute(ShellScreen.Import);

        shell.LeaveSettingsCommand.Execute(null);

        shell.IsSettingsMode.ShouldBeFalse();
        shell.IsImportOpen.ShouldBeFalse("a stale flag here would reopen the importer the next time Settings is entered");
    }

    /// <remarks>
    /// <see cref="MainWindowViewModel.Issuer"/> is new in v5c, for the Account settings page's SIGN-IN row —
    /// see the property's own remark. <c>MeResponse.Issuer</c> was already being cached into
    /// <c>StoredUnlockMaterial</c> for no reader before this wave; this is the first assertion that it also
    /// reaches the shell.
    /// </remarks>
    [Fact]
    public async Task UnlockingCarriesTheIssuerOntoTheShell_ForTheAccountPagesSignInRow()
    {
        await UnlockedAsync();

        shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
    }

    /// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
    private async Task<VaultViewModel> ReadyToConnectAsync()
    {
        await UnlockedAsync();

        var vault = shell.Vault!;

        await AddHostAsync(vault, "prod-db");
        vault.SelectedHost = vault.Hosts[0];

        return vault;
    }
}
