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