Public Access
Give the application a settings area built from what really exists
This commit is contained in:
@@ -55,6 +55,33 @@ internal static class LayoutHarness
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
/// <summary>Settings mode's own rail, from <c>SettingsNav.axaml</c> — wider than <see cref="NavRailWidth"/>.</summary>
|
||||
internal const double SettingsNavWidth = 340;
|
||||
|
||||
/// <summary>
|
||||
/// The width settings mode's own content column asks for, from the design's <c>width:1100px</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>MaxWidth</c> on the page, not a <c>Width</c> — see the same trade <c>TitleBar.axaml</c>'s own
|
||||
/// search box makes with its own <c>MaxWidth="514"</c>, and for the identical reason:
|
||||
/// <see cref="SettingsContentWidth"/> below is smaller than this at the window's minimum, and a page
|
||||
/// that insisted on the full 1100 would arrange its own rows past the edge of the rectangle settings
|
||||
/// mode actually gives them.
|
||||
/// </remarks>
|
||||
internal const double SettingsDesignContentWidth = 1100;
|
||||
|
||||
/// <summary>The width a settings page's content column actually gets at the window's minimum.</summary>
|
||||
internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth;
|
||||
|
||||
/// <summary>What settings mode leaves a page between its own titlebar and the window's bottom edge.</summary>
|
||||
/// <remarks>
|
||||
/// Settings mode has no status bar and no update banner of its own — see <c>MainWindow.axaml</c>'s own
|
||||
/// remark on why both are hidden while <c>IsSettingsMode</c> is true — so this is
|
||||
/// <see cref="MinimumHeight"/> less only <see cref="TitleBarHeight"/>, not <see cref="ContentHeight"/>'s
|
||||
/// own subtraction of <see cref="StatusBarHeight"/> too.
|
||||
/// </remarks>
|
||||
internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight;
|
||||
|
||||
/// <summary>
|
||||
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
|
||||
/// </summary>
|
||||
@@ -193,6 +220,18 @@ internal static class LayoutHarness
|
||||
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
|
||||
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
|
||||
|
||||
/// <summary>
|
||||
/// v5c-3: what the S3 usage of <c>TransfersScreen</c> gets, now that <c>MainWindow.axaml</c> gives it the
|
||||
/// session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — no tab row, header,
|
||||
/// status bar or sidebar to take further space off it.
|
||||
/// </summary>
|
||||
internal static double BucketsScreenWidth =>
|
||||
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
|
||||
|
||||
/// <inheritdoc cref="BucketsScreenWidth" />
|
||||
internal static double BucketsScreenHeight =>
|
||||
ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
|
||||
|
||||
private static readonly HeadlessUnitTestSession Session =
|
||||
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
|
||||
|
||||
|
||||
@@ -207,24 +207,35 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.</summary>
|
||||
/// <summary>
|
||||
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
|
||||
/// popover behind them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
|
||||
/// <para>
|
||||
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
|
||||
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
|
||||
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
|
||||
/// and "Preferences" are no longer the same click.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
|
||||
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
|
||||
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
|
||||
|
||||
[Fact]
|
||||
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
|
||||
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
|
||||
|
||||
[Fact]
|
||||
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
|
||||
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
|
||||
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
|
||||
|
||||
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
|
||||
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
||||
OnTheRailAsync((rail, window) =>
|
||||
{
|
||||
var chip = UserChip(rail);
|
||||
@@ -232,19 +243,20 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
|
||||
Click(PopoverRow(window, label), window);
|
||||
|
||||
shell.Screen.ShouldBe(target);
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(target);
|
||||
shell.IsShowingPages.ShouldBeTrue();
|
||||
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// Through Preferences rather than a direct <c>SignOutCommand</c> — see
|
||||
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card the mock has no
|
||||
/// room for at all is drawn inline on that one screen while the vault is unlocked, and arming it from
|
||||
/// anywhere else would be a card raised nobody could see.
|
||||
/// Through the Account settings page rather than a direct <c>SignOutCommand</c> — see
|
||||
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card is drawn inline on
|
||||
/// that one page while the vault is unlocked, and arming it from anywhere else would be a card raised
|
||||
/// nobody could see.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ThePopoversLogoutRow_GoesToPreferencesAndArmsTheSignOutConfirmation()
|
||||
public async Task ThePopoversLogoutRow_EntersSettingsOnAccountAndArmsTheSignOutConfirmation()
|
||||
{
|
||||
await OnTheRailAsync((rail, window) =>
|
||||
{
|
||||
@@ -253,7 +265,8 @@ public sealed class NavRailTests : IAsyncLifetime
|
||||
|
||||
Click(PopoverRow(window, "Logout"), window);
|
||||
|
||||
shell.Screen.ShouldBe(ShellScreen.Preferences);
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
|
||||
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
@@ -825,9 +824,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
|
||||
|
||||
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
|
||||
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
|
||||
// usage is "deliberately not given the session shell above."
|
||||
// The buckets budget, not the full session shell's: MainWindow.axaml gives S3 the same TransfersScreen
|
||||
// control inside a padded, bordered container but with no tab row, no header, no status bar and no
|
||||
// sidebar around it — see its own remark on why the S3 usage is "deliberately not given the full
|
||||
// session shell above."
|
||||
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
|
||||
}
|
||||
|
||||
@@ -847,27 +847,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
|
||||
}
|
||||
|
||||
// ---- The import screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
|
||||
{
|
||||
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
|
||||
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
|
||||
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
|
||||
/// understands and one they think is broken.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
|
||||
{
|
||||
await MeasureImportAsync(
|
||||
faults => faults.ShouldBeEmpty("with a scanned list"),
|
||||
await ScannedImportAsync());
|
||||
}
|
||||
// v5c-3: the import screen's own layout coverage moved to SettingsPagesLayoutTests — it is a settings
|
||||
// page now, drawn inside settings mode over the Preferences page rather than beside the ordinary nav
|
||||
// rail; see MainWindowViewModel.IsImportOpen and design-notes/v5c-fidelity-notes.md.
|
||||
|
||||
// ---- The host keys screen ----
|
||||
|
||||
@@ -1898,87 +1880,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasureImportAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
ImportViewModel? import = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new ImportScreen
|
||||
{
|
||||
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
|
||||
};
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// An import view model that has scanned a real file, so the table has rows in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through a temporary directory rather than by populating the rows directly, because the shape being
|
||||
/// measured is what the parser produces — an entry with two warnings under it is taller than one
|
||||
/// without, and inventing the rows would measure a layout nothing generates.
|
||||
/// </remarks>
|
||||
private async Task<ImportViewModel> ScannedImportAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-import-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(directory, "config"),
|
||||
"""
|
||||
Host *
|
||||
ServerAliveInterval 30
|
||||
|
||||
Host prod-db
|
||||
HostName database.production.internal
|
||||
User deploy
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host bastion-eu-west-1
|
||||
HostName bastion.eu-west-1.example.com
|
||||
User ops
|
||||
ProxyCommand nc %h %p
|
||||
Compression yes
|
||||
compression no
|
||||
|
||||
Match host anything
|
||||
User root
|
||||
""");
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
|
||||
|
||||
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
|
||||
// table — which is the other test.
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
|
||||
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
|
||||
|
||||
return import;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasurePinsAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
@@ -2142,12 +2043,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
|
||||
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the budget it actually gets.</summary>
|
||||
/// <remarks>
|
||||
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
|
||||
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
|
||||
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
|
||||
/// the same budget every other full-bleed page gets.
|
||||
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height. v5c-3
|
||||
/// gives S3 the session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — see
|
||||
/// <c>MainWindow.axaml</c>'s own remark on why — so it is measured at
|
||||
/// <see cref="LayoutHarness.BucketsScreenWidth"/>/<see cref="LayoutHarness.BucketsScreenHeight"/>, which
|
||||
/// take that padding and border off the full-bleed budget every other page gets and stop there: no tab
|
||||
/// row, header, status bar or sidebar to subtract, unlike <see cref="LayoutHarness.SessionScreenWidth"/>.
|
||||
/// </remarks>
|
||||
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
@@ -2156,7 +2059,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
var screen = new TransfersScreen { DataContext = transfers };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
screen, LayoutHarness.BucketsScreenWidth, LayoutHarness.BucketsScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -2190,123 +2093,12 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
bytesPerSecond,
|
||||
failure)));
|
||||
|
||||
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
|
||||
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
|
||||
/// widened the rail, because the minimum grew by exactly what the rail did.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
|
||||
/// <see cref="StubTeamServer"/>. The two states that hide half the screen, the rename form and the
|
||||
/// confirmation, are measured in their own tests below rather than here, because a control that is
|
||||
/// collapsed when the window is laid out is a control this suite has not checked.
|
||||
/// </para>
|
||||
/// v5c-2: the old VaultsScreen this suite used to measure here is gone — Vaults is a settings page now,
|
||||
/// and its own layout coverage (populated lists, the rename and new-vault forms, the hand-over
|
||||
/// confirmation, the members panel) lives in <c>SettingsPagesLayoutTests</c> beside every other settings
|
||||
/// page's, measured against the settings content budget rather than this suite's full-chrome one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => { },
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with members and key holders"));
|
||||
|
||||
/// <remarks>
|
||||
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => vaults.RenameVaultCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with the rename form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The name-a-vault form is in the left column under the vault list. Worth its own case because the
|
||||
/// column is 268 wide and the sentence under the field wraps.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults => vaults.NewVaultCommand.Execute(null),
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with the new-vault form open"));
|
||||
|
||||
/// <remarks>
|
||||
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
|
||||
/// tallest thing that can appear above the members list, so it is the case most likely to push the
|
||||
/// key-holders list off the bottom.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
|
||||
OnTheVaultsScreenAsync(
|
||||
vaults =>
|
||||
{
|
||||
vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
|
||||
vaults.HandOverCommand.Execute(null);
|
||||
},
|
||||
window => LayoutHarness.Unreachable(window)
|
||||
.ShouldBeEmpty("the vaults screen with the hand-over confirmation armed"));
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A real <c>VaultsViewModel</c> over this suite's own unlocked session and a stub server. Both halves
|
||||
/// are needed and they answer different questions: the vault list is the session's, and who is in each
|
||||
/// vault is the server's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A shared vault is created into the session first, because a session that has only ever been unlocked
|
||||
/// offline holds one personal vault — and the personal vault draws none of what this screen is for. It
|
||||
/// is created through the real <c>CreateTeamVaultAsync</c> rather than poked into the cache, so the row
|
||||
/// being measured is one the application could actually produce.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Selected before the second load rather than after it, so the members read is the awaited one: a
|
||||
/// selection assignment starts a read nothing can wait for, and measuring a window while it was still
|
||||
/// in flight would certify a screen with empty lists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OnTheVaultsScreenAsync(
|
||||
Action<VaultsViewModel> arrange,
|
||||
Action<Window> assert)
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
|
||||
var vaults = new VaultsViewModel(() => teamServer, () => session);
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.SelectedVault = vaults.Vaults.First(row => row.IsShared);
|
||||
|
||||
await vaults.LoadAsync(Token);
|
||||
|
||||
vaults.Members.ShouldNotBeEmpty("there is nothing to measure otherwise");
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
arrange(vaults);
|
||||
|
||||
var screen = new VaultsScreen { DataContext = vaults };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
|
||||
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v5c: whether each of settings mode's four pages fits the rectangle it is actually given, and whether the
|
||||
/// rows a fidelity pass could quietly unwire — Updates on General, Windows Hello on Security — are still
|
||||
/// wired to the real commands.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The budget is <see cref="LayoutHarness.SettingsContentWidth"/> by <see cref="LayoutHarness.SettingsContentHeight"/>
|
||||
/// — the space beside <c>SettingsNav</c>'s own 340 pixels, under settings mode's own titlebar and with no
|
||||
/// status bar or update banner beneath it, at the window's minimum. Not the design's own 1100-wide column,
|
||||
/// which is wider than that budget: see the <c>MaxWidth</c> remark on <see cref="LayoutHarness.SettingsDesignContentWidth"/>
|
||||
/// for why the pages ask for 1100 at most rather than exactly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A real unlocked vault, on the same reasoning <see cref="ScreenLayoutTests"/> gives: the Security page
|
||||
/// binds <c>KnownHostsScreen.Summary</c>, which is null until a vault is open, and a stand-in vault would
|
||||
/// still have to be the real <see cref="VaultViewModel"/> type for the compiled bindings to resolve at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
||||
{
|
||||
private const string Passphrase = "a sufficiently long passphrase";
|
||||
private const string ServerUrl = "https://dodossh.example";
|
||||
|
||||
private static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
private readonly FakeAccountServer server = new();
|
||||
private readonly StubKeyBinding keyBinding = new();
|
||||
private readonly VaultKnownHostStore knownHosts = new();
|
||||
|
||||
private ClientCacheFactory caches = null!;
|
||||
private TerminalWorkspace workspace = null!;
|
||||
private VaultSession session = null!;
|
||||
private VaultViewModel vault = null!;
|
||||
private MainWindowViewModel shell = null!;
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
caches = ClientCacheFactory.ForMemory($"settings-layout-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||
session = outcome.Session!;
|
||||
|
||||
workspace = new TerminalWorkspace(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
await knownHosts.OpenAsync(session, Token);
|
||||
|
||||
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||
|
||||
shell = new MainWindowViewModel(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-settings-layout-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
knownHosts,
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile)
|
||||
{
|
||||
State = ShellState.Unlocked,
|
||||
Vault = vault,
|
||||
AccountName = "Ripley Vega",
|
||||
Email = "ripley@example.test",
|
||||
Issuer = "https://sso.example.test/realms/dodotech",
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await shell.DisposeAsync();
|
||||
await vault.DisposeAsync();
|
||||
knownHosts.Close();
|
||||
await workspace.DisposeAsync();
|
||||
await session.DisposeAsync();
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheGeneralPageFitsAtTheWindowsMinimum() =>
|
||||
MeasureAsync(() => new SettingsGeneralPage(), faults => faults.ShouldBeEmpty());
|
||||
|
||||
[Fact]
|
||||
public Task ThePreferencesPageFitsAtTheWindowsMinimum() =>
|
||||
MeasureAsync(() => new SettingsPreferencesPage(), faults => faults.ShouldBeEmpty());
|
||||
|
||||
[Fact]
|
||||
public Task TheAccountPageFitsAtTheWindowsMinimum() =>
|
||||
MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("the ordinary shape"));
|
||||
|
||||
/// <remarks>The other shape the Account page takes — the sign-out confirmation card in place of the row.</remarks>
|
||||
[Fact]
|
||||
public async Task TheAccountPageFitsWithTheSignOutConfirmationUp()
|
||||
{
|
||||
shell.SignOutCommand.Execute(null);
|
||||
shell.IsConfirmingSignOut.ShouldBeTrue();
|
||||
|
||||
await MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("with the confirm card up"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheSecurityPageFitsWhileThisMachineCanRegisterADeviceKey()
|
||||
{
|
||||
shell.CanRegisterDevice = true;
|
||||
shell.CanForgetDevice = false;
|
||||
|
||||
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to register"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheSecurityPageFitsWhileThisMachineIsAlreadyRegistered()
|
||||
{
|
||||
shell.CanRegisterDevice = false;
|
||||
shell.CanForgetDevice = true;
|
||||
|
||||
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to withdraw"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public Task TheSecurityPageFitsOnAMachineWithNowhereToKeepADeviceKey()
|
||||
{
|
||||
shell.CanRegisterDevice = false;
|
||||
shell.CanForgetDevice = false;
|
||||
shell.HasNoDeviceKeyOption.ShouldBeTrue();
|
||||
|
||||
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("the no-TPM explanation"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checking for updates on the General page is still the real <c>UpdateViewModel</c> command, not a
|
||||
/// row a fidelity pass silently detached while restyling it into the card idiom.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public Task TheGeneralPagesCheckNowButton_IsWiredToTheRealUpdatesCommand() =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var page = new SettingsGeneralPage { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "CHECK NOW").Command.ShouldBeSameAs(shell.Updates.CheckNowCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// Windows Hello's two commands — moved here whole from the old Preferences screen — are reachable from
|
||||
/// the Security page in both of the states they can be in.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TheSecurityPagesHelloButtons_AreWiredToTheRealDeviceCommands()
|
||||
{
|
||||
shell.CanRegisterDevice = true;
|
||||
shell.CanForgetDevice = false;
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var page = new SettingsSecurityPage { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "REGISTER").Command.ShouldBeSameAs(shell.RegisterDeviceCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
shell.CanRegisterDevice = false;
|
||||
shell.CanForgetDevice = true;
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var page = new SettingsSecurityPage { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
ButtonNamed(window, "STOP UNLOCKING HERE").Command.ShouldBeSameAs(shell.ForgetDeviceCommand);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
// ---- v5c-2: Vaults, Groups, Tags ----
|
||||
//
|
||||
// Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at
|
||||
// LayoutHarness.ScreenWidth/ScreenHeight (the space beside the ordinary nav rail); it is a settings page
|
||||
// now, so its own layout coverage belongs here, against the narrower settings budget, beside every other
|
||||
// settings page's. Groups and Tags are new pages with nothing to migrate.
|
||||
|
||||
/// <remarks>
|
||||
/// A shared vault added straight into the fixture's own session — the same technique the retired
|
||||
/// VaultsScreen layout tests used — so the card list draws both the personal vault and a shared one
|
||||
/// without a live server: <c>VaultRowViewModel.IsShared</c> only asks whether the vault carries a team
|
||||
/// id, which this sets without needing <see cref="MainWindowViewModel"/>'s own (offline) connection.
|
||||
/// </remarks>
|
||||
private async Task AddSharedVaultAsync()
|
||||
{
|
||||
using var teamServer = new StubTeamServer();
|
||||
|
||||
await session.CreateTeamVaultAsync(
|
||||
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheVaultListPopulated()
|
||||
{
|
||||
await AddSharedVaultAsync();
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("a personal vault and a shared one"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheNewVaultFormOpen()
|
||||
{
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
shell.Vaults.NewVaultCommand.Execute(null);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("the new-vault form open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheRenameFormOpen()
|
||||
{
|
||||
await AddSharedVaultAsync();
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
shell.Vaults.RenameVaultRowCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("the rename form open"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The panel's own geometry — the ListBoxes, the SHARE KEY/WITHDRAW KEY row, the KEY HOLDERS list —
|
||||
/// with nothing in Members or Grants, since the fixture's connection is offline and both are read from
|
||||
/// the server on selection. An empty ListBox is zero pixels tall and exempt from this harness's own
|
||||
/// "no size" rule, so this is still a real check of everything around it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultsPageFitsWithTheMembersPanelOpen()
|
||||
{
|
||||
await AddSharedVaultAsync();
|
||||
await shell.Vaults.LoadAsync(Token);
|
||||
|
||||
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsVaultsPage(),
|
||||
faults => faults.ShouldBeEmpty("the members panel open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheGroupsPageFitsWithGroupsPopulated()
|
||||
{
|
||||
await AddGroupAsync("production");
|
||||
await AddGroupAsync("staging");
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsGroupsPage(),
|
||||
faults => faults.ShouldBeEmpty("two groups and the No group footer"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheGroupsPageFitsWithTheEditorOpen()
|
||||
{
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsGroupsPage(),
|
||||
faults => faults.ShouldBeEmpty("the group editor open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheGroupsPageFitsWithTheDeleteConfirmationArmed()
|
||||
{
|
||||
await AddGroupAsync("production");
|
||||
|
||||
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsGroupsPage(),
|
||||
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTagsPageFitsWithTagsPopulated()
|
||||
{
|
||||
await AddTagAsync("production");
|
||||
await AddTagAsync("staging");
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsTagsPage(),
|
||||
faults => faults.ShouldBeEmpty("two tags"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTagsPageFitsWithTheEditorOpen()
|
||||
{
|
||||
vault.NewTagCommand.Execute(null);
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsTagsPage(),
|
||||
faults => faults.ShouldBeEmpty("the tag editor open"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheTagsPageFitsWithTheDeleteConfirmationArmed()
|
||||
{
|
||||
await AddTagAsync("production");
|
||||
|
||||
vault.DeleteTagRowCommand.Execute(vault.Tags.Single());
|
||||
|
||||
await MeasureAsync(
|
||||
() => new SettingsTagsPage(),
|
||||
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
|
||||
}
|
||||
|
||||
private async Task AddGroupAsync(string label)
|
||||
{
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = label;
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private async Task AddTagAsync(string label)
|
||||
{
|
||||
vault.NewTagCommand.Execute(null);
|
||||
vault.TagEditorLabel = label;
|
||||
|
||||
await vault.SaveTagCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// ---- v5c-3: Import ----
|
||||
//
|
||||
// The importer moved into settings mode's own chrome — see MainWindowViewModel.IsImportOpen and
|
||||
// design-notes/v5c-fidelity-notes.md — so its layout coverage moved here from ScreenLayoutTests, against
|
||||
// the same SettingsContentWidth/SettingsContentHeight budget every other settings page is measured
|
||||
// against, rather than the plain full-bleed one it used to get beside the ordinary nav rail.
|
||||
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
|
||||
{
|
||||
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
|
||||
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
|
||||
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
|
||||
/// understands and one they think is broken.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
|
||||
{
|
||||
await MeasureImportAsync(
|
||||
faults => faults.ShouldBeEmpty("with a scanned list"),
|
||||
await ScannedImportAsync());
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
/// <remarks>
|
||||
/// The page is built by the factory rather than handed in already constructed: an Avalonia control is
|
||||
/// owned by whichever thread creates it, and every caller of this helper must build its page on the
|
||||
/// dispatcher thread <see cref="LayoutHarness.OnTheUiThreadAsync"/> switches onto, not on the test
|
||||
/// runner's own thread the factory is captured from.
|
||||
/// </remarks>
|
||||
private Task MeasureAsync(Func<UserControl> page, Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var control = page();
|
||||
control.DataContext = shell;
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
control, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
private static Button ButtonNamed(Visual root, string label) =>
|
||||
root.GetVisualDescendants()
|
||||
.OfType<Button>()
|
||||
.First(button => string.Equals(button.Content as string, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Lays the importer out at the budget settings mode's own content column actually gets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not built through <see cref="MeasureAsync"/>: every other settings page is typed to
|
||||
/// <see cref="MainWindowViewModel"/> and takes <see cref="shell"/> as its data context, where
|
||||
/// <c>ImportScreen</c> is typed to <c>ImportViewModel</c> — the same split <c>SettingsView.axaml</c>
|
||||
/// draws by handing it <c>{Binding ImportScreen}</c> rather than the shell itself.
|
||||
/// </remarks>
|
||||
private Task MeasureImportAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
ImportViewModel? import = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new ImportScreen
|
||||
{
|
||||
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
|
||||
};
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// An import view model that has scanned a real file, so the table has rows in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through a temporary directory rather than by populating the rows directly, because the shape being
|
||||
/// measured is what the parser produces — an entry with two warnings under it is taller than one
|
||||
/// without, and inventing the rows would measure a layout nothing generates.
|
||||
/// </remarks>
|
||||
private async Task<ImportViewModel> ScannedImportAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-settings-import-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(directory, "config"),
|
||||
"""
|
||||
Host *
|
||||
ServerAliveInterval 30
|
||||
|
||||
Host prod-db
|
||||
HostName database.production.internal
|
||||
User deploy
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host bastion-eu-west-1
|
||||
HostName bastion.eu-west-1.example.com
|
||||
User ops
|
||||
ProxyCommand nc %h %p
|
||||
Compression yes
|
||||
compression no
|
||||
|
||||
Match host anything
|
||||
User root
|
||||
""");
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
|
||||
|
||||
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
|
||||
// table — which is the other test.
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
|
||||
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
|
||||
|
||||
return import;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user