Public Access
The address at the head of the session column is the one place the desktop head prints the endpoint it actually dialled, and it was a TextBlock — which in Avalonia is a drawing of a string rather than a string. The next thing people do with an endpoint is paste it somewhere: into a second client, into a ticket, into the ssh command being written in the window beside this one. Retyping is how a digit goes missing from an address that is mostly digits. SelectableTextBlock, which is what the rest of this application already reaches for wherever a string is on screen to be taken away — a fingerprint, a public key, a recovery code, a snippet's command. Nothing else about the row moves: same font, weight, colour and trimming, same tooltip, same close button beside it. The sidebar is one control drawn on both surfaces, so SFTP gets it for the same reason it gets everything else in that block. The trimming and the selection do disagree at the long end, and the fix for that is not to drop the trimming: the ellipsis is there because a long account@host:port would otherwise push the close button off the edge of a panel whose whole point is that it can be got rid of. A drag reaches only the glyphs that were drawn, so a trimmed address has to be taken with select-all — and select-all takes the whole thing, because the ellipsis is a drawing and not a shorter value. That is the half a test can quietly lose, so it is the half the new test asserts, on the longest address the suite has rather than on one that fits and would pass either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
8.8 KiB
C#
210 lines
8.8 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <c>MainWindow.axaml</c> copes with it by asking this
|
|
/// control how wide it is rather than by knowing.
|
|
/// </remarks>
|
|
public sealed class SessionSidebarTests : IAsyncLifetime
|
|
{
|
|
/// <summary>The widths <c>SessionSidebar.axaml</c> declares for its two states.</summary>
|
|
private const double OpenWidth = 300;
|
|
|
|
/// <inheritdoc cref="OpenWidth" />
|
|
private const double RailWidth = 34;
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <inheritdoc />
|
|
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<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
shell = new MainWindowViewModel(
|
|
new ClientPaths(directory),
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
Substitute.For<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>())
|
|
{
|
|
State = ShellState.Unlocked,
|
|
};
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The address trims and the close button stays where it is; that is the whole claim. Asserted through
|
|
/// <see cref="LayoutHarness.Unreachable"/> 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.
|
|
/// </remarks>
|
|
[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();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, _) =>
|
|
{
|
|
var button = sidebar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(candidate => candidate.Classes.Contains("headerghost"));
|
|
|
|
button.Content.ShouldBe("Open SFTP");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The address is the one string in this column somebody has to be able to take away with them, and the
|
|
/// claim under test is the half of that which the trimming could quietly break: an ellipsis is a drawing,
|
|
/// not a shorter string, so select-all reaches the whole address and not the part that happened to fit.
|
|
/// Asserted on the longest address this suite has — the case where the two could disagree — rather than
|
|
/// on one that fits, which would pass whether the trimming were render-only or not.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheAddressSelectsAndCopiesWholeEvenWhereItIsTrimmed()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, _) =>
|
|
{
|
|
// Single rather than First: this column prints exactly one string that is there to be copied, and
|
|
// a second selectable one appearing beside it is a question this test should ask rather than skip.
|
|
var address = sidebar.GetVisualDescendants().OfType<SelectableTextBlock>().Single();
|
|
|
|
address.Text.ShouldBe(LongAddress);
|
|
|
|
address.SelectAll();
|
|
|
|
address.SelectedText.ShouldBe(LongAddress);
|
|
address.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
|
|
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
|
|
/// on it is the difference between a panel somebody closed and a panel somebody lost.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClosingTheColumnLeavesTheRailThatBringsItBack()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, window) =>
|
|
{
|
|
shell.IsSessionSidebarOpen = false;
|
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
sidebar.DesiredSize.Width.ShouldBe(RailWidth);
|
|
|
|
var grip = sidebar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.Single(candidate => candidate.Classes.Contains("sidebargrip") && candidate.IsEffectivelyVisible);
|
|
|
|
var centre = grip.TranslatePoint(new Point(grip.Bounds.Width / 2, grip.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the grip is not in this window's tree");
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
shell.IsSessionSidebarOpen.ShouldBeTrue("the rail's own button is what reopens the column");
|
|
|
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
});
|
|
}
|
|
|
|
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
try
|
|
{
|
|
body(sidebar, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|