Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/NavRailTests.cs
jaap-jan ccaf7a8e72
ci / build and test (pull_request) Failing after 2m33s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m34s
Give the terminal back the width and the keyboard the session shell took
Seven things reported from a day's use of v5b's session shell, and they are one
commit because five of them are the same complaint from different angles: the
window spends too much of itself on chrome describing the session, and the parts
that are not chrome do not behave.

◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design.

Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the
address on the left, a cross-surface button on the right. Both facts are worth
having and the strip they sat in was not — the tab already names the host, and a
full-width bar repeating it was the cheapest 60 pixels in the layout to give
back. The address is now the first line of the sidebar and the button is
stretched across the column under it, so nothing is lost and the pane is taller.

Which word that button carries and which command it runs used to be handed in
from the two usage sites in MainWindow.axaml, because the control was drawn
twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and
OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress
already decides which surface's fact to read. The two directions underneath are
untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync
for a fresh terminal at whatever SFTP has open.

SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops
subtracting its 60 pixels from every session screen's budget — the same
treatment the retired window-wide tab strip got, and for the same reason: a
constant for chrome nobody draws is a suite quietly measuring the wrong
rectangle.

◆ AND THE SIDEBAR CLOSES, which the design has no state for at all.

300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is
often two rows long. The column now folds to a 34-pixel rail carrying the
chevron that brings it back — a rail rather than nothing, because a panel that
vanishes leaving no trace is one people report as lost rather than as closed.
Both states live in the one control and swap on IsSessionSidebarOpen, so
MainWindow's own "Auto" column takes whichever width is showing without knowing
the state exists.

Written through to ClientSettings.SessionSidebarOpen rather than held for the
session. It is a decision about how much of the window a terminal gets, and one
that had to be made again on every launch would not really be on offer.

---- THE FOUR SMALLER ONES ----

A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it
got there. Two causes with nothing in common. The click moved Win32 focus onto
the sidebar row, and term.focus() in the page cannot take it back — only the
host can, so the shell raises TerminalFocusRequested and the window answers with
the same posted focus every other path here uses. The highlight was bash: xterm
wraps a paste in bracketed-paste markers, readline marks what arrives inside
them as an active region, and it stays in reverse video until the next
keystroke. Right for a clipboard paste, wrong for a snippet picked off a
sidebar. Single-line snips are typed rather than pasted now, which needs no
markers; multi-line still pastes, because "runs three commands unasked" is the
worse of the two failures and the markers are the whole of what prevents it.

A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling
viewport #000 — its own comment explains why, and it is a macOS scrollbar
concern. Everywhere else that black is covered by the rows, except along the
bottom: the fit addon floors the row count, so the remainder below the last
whole row is bare viewport, up to a line tall, against this page's #171a26. The
light square at its right-hand end is where WebView2's classic scrollbar corner
lands. The viewport is repainted in the page's own background, and the scrollbar
with it — thin and in these colours rather than a grey Windows channel down the
side of a near-black terminal, and kept rather than hidden, because a surface
that scrolls with no sign that it does is worse than a quiet bar.

THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face
at all — that file is the 2019 build and its cmap skips E944 and E946 — so the
rail's Pins row and the hosts screen's own pin badge have both been drawing a
missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage
is U+F10D, verified against the file rather than against a codepoints table for
a later release of the font. Every other icon codepoint in the repository was
audited the same way; this was the only miss.

THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip
reading ⌘K, and this build substitutes CTRL K — six characters and a space,
wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's
footprint survives for the day this face has a ⌘ to draw.

---- AND THE POPOVER UNDER THE USER CHIP ----

Reported as not matching the design, and it was not: Button.poprow set a corner
radius and a padding and never touched the Background, so every row wore the
Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the
design draws as six lines of text — and the hover rule underneath was already
correct and simply invisible against a fill that never went away. Set on the
ContentPresenter as well as on the Button, the same as Button.flat, because the
theme binds its brush there and a Background set only on the control loses to
it. The panel itself gets this window's own radius-12 card treatment through a
FlyoutPresenter class rather than by widening the shared context-menu rule, and
Vaults and Preferences stop being drawn one step dimmer than Settings and
Logout, which read as two disabled entries in a menu of five live ones.

---- WHAT PROVES IT ----

Three tests in the layout suite, two of them checked against the defect they
describe: the popover row's resting fill (fails with #33ffffff without the
style), and the kbd chip against the natural width of its own text, measured on
a detached copy because a TextBlock's DesiredSize is already clipped to what it
was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests
is new — the sidebar has never been laid out by a test, and it now holds a
string of unbounded length beside a button that has to stay clickable. In the
shell suite: the cross-surface row in both directions, the closed state
surviving to disk, and the focus request being made when a snip lands and not
made when it does not.
2026-08-10 16:49:44 +02:00

397 lines
17 KiB
C#

using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
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>
/// How the rail's own switcher, mode-dependent first row and user popover answer a pointer.
/// </summary>
/// <remarks>
/// <para>
/// v5b moved three things onto this control that used to be tested elsewhere or not at all: the SSH/SFTP/S3
/// choice that used to be the tab strip's own fixed tabs (see <c>TerminalTabsTests</c>, which used to hold
/// the equivalent of the first two facts below), the mode-dependent first row the design calls its own
/// <c>mode</c> prop, and the popover that replaced the strip's vault menu. This suite is this control's
/// counterpart to that one — a minimal shell with two tabs and no vault, for the reason
/// <c>TerminalTabsTests</c> gives: nothing here reads <c>Vault</c> except the popover's vault switches,
/// which this suite therefore does not open — that is <c>VaultVisibilityTests</c>' business, over the
/// commands themselves, and this suite would only be re-testing the same command through a slower door.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the same reason the palette's and the strip's suites are one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>.
/// </para>
/// </remarks>
public sealed class NavRailTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"navrail-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
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();
}
/// <summary>
/// The switcher's three segments select the surface they name, and light up when it is the one showing.
/// </summary>
/// <remarks>
/// Driven through the segments rather than through the commands directly, for the reason
/// <c>TerminalTabsTests.TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage</c> gave for its own
/// three fixed tabs: what is being checked is that three buttons in the markup are wired to three
/// different things, which three commands called by hand would not catch if two of the three were
/// bound to the same one.
/// </remarks>
[Fact]
public async Task TheSwitcherSegmentsSelectTheirSurface_AndLightTheActiveOne()
{
await OnTheRailAsync((rail, window) =>
{
shell.IsSshShowing.ShouldBeTrue("nothing has navigated to SFTP or S3 yet");
Segment(rail, "SSH").Classes.Contains("active").ShouldBeTrue();
Click(Segment(rail, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
Segment(rail, "SFTP").Classes.Contains("active").ShouldBeTrue();
Segment(rail, "SSH").Classes.Contains("active").ShouldBeFalse("exactly one segment lights at once");
Click(Segment(rail, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Segment(rail, "S3").Classes.Contains("active").ShouldBeTrue();
Click(Segment(rail, "SSH"), window);
shell.IsSshShowing.ShouldBeTrue();
shell.IsBucketsShowing.ShouldBeFalse();
});
}
/// <summary>
/// The mode-dependent first row follows the same three flags the switcher above lights.
/// </summary>
/// <remarks>
/// The label is read straight off the row's own bound text rather than off <see cref="MainWindowViewModel"/>
/// state directly, because what a fidelity pass could break is the binding between the two, not the
/// property computing the right string on its own — <c>MainWindowViewModelTests</c> would already catch
/// that half.
/// </remarks>
[Fact]
public async Task TheFirstRailItemsLabel_FollowsTheSwitchersMode()
{
await OnTheRailAsync((rail, window) =>
{
FirstRow(rail).ShouldBe("Terminal");
Click(Segment(rail, "SFTP"), window);
FirstRow(rail).ShouldBe("Files");
Click(Segment(rail, "S3"), window);
FirstRow(rail).ShouldBe("Buckets");
Click(Segment(rail, "SSH"), window);
FirstRow(rail).ShouldBe("Terminal");
});
}
/// <remarks>
/// The user popover is a <c>Flyout</c>, and this is the same assertion
/// <c>TerminalTabsTests.TheCaretBesideVaults_OpensTheVaultMenu</c> made of the strip's own — the pointer
/// opens the popup, and nothing about its position or its content occlude anything, since it never
/// crosses into the terminal's own rectangle. See the remark in NavRail.axaml.
/// </remarks>
[Fact]
public async Task ClickingTheUserChip_OpensThePopover()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
Click(chip, window);
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeTrue();
});
}
/// <summary>
/// Each vault switch in the popover draws the vault's own display name and shown state, and is wired
/// to the command that toggles it.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="MainWindowViewModel.VaultToggles"/> is fed directly rather than through a real sign-in
/// and a second vault created on a fake server. <c>VaultVisibilityTests</c>, in the Avalonia-free
/// <c>DodoSSH.Client.App.Tests</c> project, already proves <see cref="MainWindowViewModel.ToggleVaultCommand"/>
/// itself — that a hidden vault stays syncing, that the personal one refuses, and everything else the
/// command actually does once it runs. What is worth proving here, in the project that can lay markup
/// out at all, is only the wiring: that a row in this popover shows the right vault and calls that
/// command with that vault when pressed — the fact a fidelity pass to this file could actually break.
/// </para>
/// <para>
/// The command is read off the row rather than pressed, because <see cref="MainWindowViewModel.ToggleVaultCommand"/>
/// itself declines with no observable effect when <see cref="MainWindowViewModel.Vault"/> is null — which
/// it is here, for the reason above — so a press would prove nothing a reader could tell from a press
/// that reached the wrong command entirely.
/// </para>
/// </remarks>
[Fact]
public async Task PopoverVaultRows_NameTheirVaultAndAreWiredToToggleIt()
{
var shown = new VaultToggleViewModel(Guid.CreateVersion7(), "Personal", IsPersonal: true, IsShown: true);
var hidden = new VaultToggleViewModel(Guid.CreateVersion7(), "Platform secrets", IsPersonal: false, IsShown: false);
shell.VaultToggles.Add(shown);
shell.VaultToggles.Add(hidden);
await OnTheRailAsync((rail, window) =>
{
Click(UserChip(rail), window);
var shownRow = PopoverRow(window, shown);
var hiddenRow = PopoverRow(window, hidden);
shownRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
shownRow.CommandParameter.ShouldBeSameAs(shown);
HasVisibleCheck(shownRow).ShouldBeTrue("the personal vault is always shown");
hiddenRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
hiddenRow.CommandParameter.ShouldBeSameAs(hidden);
HasVisibleCheck(hiddenRow).ShouldBeFalse("this one was switched off");
});
}
/// <summary>
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
/// popover behind them.
/// </summary>
/// <remarks>
/// <para>
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
/// and "Preferences" are no longer the same click.
/// </para>
/// <para>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
/// </para>
/// </remarks>
[Fact]
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
[Fact]
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
[Fact]
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, label), window);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(target);
shell.IsShowingPages.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
});
/// <remarks>
/// Through the Account settings page rather than a direct <c>SignOutCommand</c> — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card is drawn inline on
/// that one page while the vault is unlocked, and arming it from anywhere else would be a card raised
/// nobody could see.
/// </remarks>
[Fact]
public async Task ThePopoversLogoutRow_EntersSettingsOnAccountAndArmsTheSignOutConfirmation()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, "Logout"), window);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
shell.IsConfirmingSignOut.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
});
}
/// <summary>
/// Every row in the popover rests flat, and the pointer is what fills one.
/// </summary>
/// <remarks>
/// <c>Button.poprow</c> set a radius and a padding and left the Background alone, so each row wore the
/// Fluent theme's own button fill: the account menu drew as six raised pills where the design draws six
/// lines of text. Read as a colour off the templated presenter rather than off the Button, because that
/// is where the theme puts its brush and therefore the only place the absence of one can be proven.
///
/// The hover half is asserted too, and it is what stops "flat" being fixed by making the rows
/// permanently invisible to the pointer: a menu row that does not answer a pointer at all is a worse
/// answer than one that answers wrongly.
/// </remarks>
[Fact]
public async Task PopoverRowsAreFlatUntilThePointerFindsThem()
{
await OnTheRailAsync((rail, window) =>
{
Click(UserChip(rail), window);
var row = PopoverRow(window, "Settings");
var presenter = row.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(candidate => candidate.Name is "PART_ContentPresenter");
var resting = presenter.Background as ISolidColorBrush;
(resting is null || resting.Color.A == 0).ShouldBeTrue(
$"a popover row rests flat, and this one is filled with {resting?.Color}");
var centre = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the row is not in this window's tree");
window.MouseMove(centre);
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
row.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
(presenter.Background as ISolidColorBrush).ShouldNotBeNull().Color.A.ShouldNotBe(
(byte)0,
"a row that does not change under the pointer is one nobody can tell is clickable");
});
}
// ---- Helpers ----
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail { DataContext = shell };
var window = new Window { Content = rail };
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
try
{
body(rail, window);
}
finally
{
window.Close();
}
},
Token);
private static Button Segment(Visual rail, string label) =>
rail.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("navseg")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static Button UserChip(Visual rail) =>
rail.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("navuser"));
/// <summary>
/// A row inside the open popover, found by its own data context. Searched from the window rather than
/// from the rail: a <c>Flyout</c>'s content is a popup, hosted in the window's own overlay layer rather
/// than nested inside the control that owns it, so it is outside <c>rail.GetVisualDescendants()</c>.
/// </summary>
private static Button PopoverRow(Visual window, VaultToggleViewModel toggle) =>
window.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("poprow") && ReferenceEquals(button.DataContext, toggle));
/// <summary>A navigation row inside the open popover, found by the word on it.</summary>
/// <inheritdoc cref="PopoverRow(Visual, VaultToggleViewModel)" path="/summary" />
private static Button PopoverRow(Visual window, string label) =>
window.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("poprow")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
private static bool HasVisibleCheck(Button row) =>
row.GetVisualDescendants().OfType<Border>().Any(
border => border.Classes.Contains("vaultcheck") && border.IsVisible);
/// <summary>The mode-dependent first row's own label, read off its bound <c>TextBlock</c>.</summary>
private static string FirstRow(Visual rail) =>
rail.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("nav") && !button.Classes.Contains("navseg"))
.GetVisualDescendants()
.OfType<TextBlock>()
.First(text => text.Classes.Contains("navlabel"))
.Text ?? string.Empty;
private static void Click(Visual control, Window window)
{
var at = control.TranslatePoint(
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
}