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;
///
/// 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.
///
///
///
/// The budget is by
/// — the space beside SettingsNav'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 MaxWidth remark on
/// for why the pages ask for 1100 at most rather than exactly.
///
///
/// A real unlocked vault, on the same reasoning gives: the Security page
/// binds KnownHostsScreen.Summary, which is null until a vault is open, and a stand-in vault would
/// still have to be the real type for the compiled bindings to resolve at all.
///
///
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;
///
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(StringComparer.Ordinal)),
Substitute.For(),
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(),
CheapProfile)
{
State = ShellState.Unlocked,
Vault = vault,
AccountName = "Ripley Vega",
Email = "ripley@example.test",
Issuer = "https://sso.example.test/realms/dodotech",
};
}
///
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"));
/// The other shape the Account page takes — the sign-out confirmation card in place of the row.
[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"));
}
///
/// Checking for updates on the General page is still the real UpdateViewModel command, not a
/// row a fidelity pass silently detached while restyling it into the card idiom.
///
[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);
///
/// 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.
///
[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.
///
/// 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: VaultRowViewModel.IsShared only asks whether the vault carries a team
/// id, which this sets without needing 's own (offline) connection.
///
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"));
}
///
/// 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.
///
[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"));
}
///
/// 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.
///
[Fact]
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
{
await MeasureImportAsync(
faults => faults.ShouldBeEmpty("with a scanned list"),
await ScannedImportAsync());
}
// ---- Helpers ----
///
/// 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 switches onto, not on the test
/// runner's own thread the factory is captured from.
///
private Task MeasureAsync(Func page, Action> 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