Give the application a settings area built from what really exists

This commit is contained in:
2026-08-08 14:17:19 +02:00
parent 422d5ca10e
commit c8507b44fe
42 changed files with 3810 additions and 1083 deletions
@@ -3815,6 +3815,66 @@ public sealed class ShellFlowTests : IAsyncLifetime
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
@@ -4332,6 +4392,150 @@ public sealed class ShellFlowTests : IAsyncLifetime
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>
@@ -4435,6 +4639,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
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
@@ -6282,6 +6518,46 @@ public sealed class ShellFlowTests : IAsyncLifetime
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()
{
@@ -7915,6 +8191,249 @@ public sealed class ShellFlowTests : IAsyncLifetime
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()
{
@@ -1910,6 +1910,85 @@ public sealed class VaultSharingTests : IAsyncLifetime
.ShouldBe("Platform");
}
/// <remarks>
/// v5c-2: the settings Vaults page draws one card per vault with no list selection to lean on, so
/// <c>RenameVaultRow</c>/<c>DeleteVaultRow</c> select the row first and then hand off to the commands
/// above — this proves the hand-off selects the right vault and reaches the same form.
/// </remarks>
[Fact]
public async Task RenameVaultRow_SelectsTheCardThenOpensTheSameFormRenameVaultDoes()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
// A different vault selected first, so the row argument is what actually decides which one the
// form is about rather than whatever was already selected.
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.RenameVaultRowCommand.Execute(shared);
vaults.SelectedVault.ShouldBe(shared);
vaults.IsRenamingVault.ShouldBeTrue();
vaults.EditVaultName.ShouldBe("Platform secrets");
}
[Fact]
public async Task DeleteVaultRow_SelectsTheCardThenArmsTheSameConfirmationDeleteVaultDoes()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.DeleteVaultRowCommand.Execute(shared);
vaults.SelectedVault.ShouldBe(shared);
vaults.IsConfirming.ShouldBeTrue();
vaults.PendingAction!.Question.ShouldContain("Platform secrets");
}
/// <remarks>
/// The settings page's members panel: pressing the card's members icon on a vault that is not already
/// selected has to select it first, or the panel would open over whichever vault the list last landed
/// on rather than the one that was actually clicked.
/// </remarks>
[Fact]
public async Task OpenMembersPanel_SelectsTheVaultItWasOpenedForAndReadsItsMembers()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.IsMembersPanelOpen.ShouldBeFalse();
vaults.OpenMembersPanelCommand.Execute(shared);
vaults.IsMembersPanelOpen.ShouldBeTrue();
vaults.SelectedVault.ShouldBe(shared);
// OpenMembersPanel's own selection assignment starts a read nothing here can await — see
// OnSelectedVaultChanged — so this reads it again through LoadAsync, which is awaited, rather than
// racing the fire-and-forget one.
await vaults.LoadAsync(Token);
vaults.Members.ShouldContain(member => member.IsSelf);
vaults.CloseMembersPanelCommand.Execute(null);
vaults.IsMembersPanelOpen.ShouldBeFalse();
}
/// <remarks>
/// <para>
/// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>