using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Input; using Avalonia.VisualTree; using DodoSSH.Client.App.Views; using DodoSSH.Client.Session; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using NSubstitute; namespace DodoSSH.Client.App.Layout.Tests; /// /// The session shell's right-hand column: its two widths, and what a long address does to the row it shares. /// /// /// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block /// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that /// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width /// column holding a string of unbounded length beside a button that must stay clickable; the second is a /// width the rest of the window has to cope with, and MainWindow.axaml copes with it by asking this /// control how wide it is rather than by knowing. /// public sealed class SessionSidebarTests : IAsyncLifetime { /// The widths SessionSidebar.axaml declares for its two states. private const double OpenWidth = 300; /// private const double RailWidth = 34; /// /// Long on purpose, and longer than the column is wide at this font: the address is the one string here /// whose length nobody controls, and it shares its row with the button that closes the column. /// private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022"; private string directory = null!; private ClientCacheFactory caches = null!; private TerminalWorkspace workspace = null!; private MainWindowViewModel shell = null!; private static CancellationToken Token => TestContext.Current.CancellationToken; /// public ValueTask InitializeAsync() { // A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written // through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths // would be editing the preferences of whoever ran it. directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}"); caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}"); workspace = new TerminalWorkspace( new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)), Substitute.For(), TimeProvider.System); shell = new MainWindowViewModel( new ClientPaths(directory), caches, workspace, new VaultKnownHostStore(), Substitute.For(), (_, _) => throw new NotSupportedException("nothing here signs in"), TimeProvider.System, Substitute.For()) { State = ShellState.Unlocked, }; return ValueTask.CompletedTask; } /// public async ValueTask DisposeAsync() { await shell.DisposeAsync(); await workspace.DisposeAsync(); caches.Dispose(); if (Directory.Exists(directory)) { Directory.Delete(directory, recursive: true); } } /// /// The address trims and the close button stays where it is; that is the whole claim. Asserted through /// rather than by reading the address's own width, because what /// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing /// beside it was pushed out of the column to make room. /// [Fact] public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt() { await OnTheSidebarAsync((sidebar, window) => { // DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on // the Border inside it, so what the surrounding "Auto" column is given — and what this asks for // — is what the control asks for, not how wide a host window happened to stretch it. sidebar.DesiredSize.Width.ShouldBe(OpenWidth); shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one"); LayoutHarness.Unreachable(window).ShouldBeEmpty(); }); } /// /// The cross-surface button that used to live in the header row. Read off the control rather than off the /// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still /// draw as an empty button — fails this. /// [Fact] public async Task TheCrossSurfaceButtonNamesTheOtherSurface() { await OnTheSidebarAsync((sidebar, _) => { var button = sidebar.GetVisualDescendants() .OfType