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