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