Give the window its v5b chrome and each session surface its own shell

This commit is contained in:
2026-08-08 00:49:59 +02:00
parent 1b76c51fbb
commit 43c939b697
30 changed files with 4433 additions and 1772 deletions
@@ -30,11 +30,16 @@ internal static class LayoutHarness
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
/// than the one a user is allowed to drag to.
///
/// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added —
/// see <see cref="TitleBarHeight"/>, <see cref="NavRailWidth"/> and the matching remark in
/// <c>MainWindow.axaml</c>. <see cref="ScreenWidth"/> and <see cref="ScreenHeight"/> are both unchanged
/// by the move, because the minimum grew by exactly what the two grew by.
/// </remarks>
internal const double MinimumWidth = 1016;
internal const double MinimumWidth = 1081;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
internal const double MinimumHeight = 583;
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
/// <remarks>
@@ -47,33 +52,95 @@ internal static class LayoutHarness
internal const double HostDrawerWidth = 320;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
internal const double NavRailWidth = 255;
/// <summary>
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// </summary>
/// <remarks>
/// All three are fixed heights declared in their own markup — 44, 42 and 24 — rather than shapes that
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
/// controls to those numbers, so the budget below cannot drift away from what the window actually
/// leaves.
/// Both are fixed heights declared in their own markup — 53 and 24 — rather than shapes that grow with
/// their contents, which is what makes stating them here honest. A test holds each control to its own
/// number, so the budget below cannot drift away from what the window actually leaves.
///
/// v5b: the titlebar's own 44 became 53, the design's own height; see <see cref="MinimumHeight"/> for
/// the matching rise that keeps every screen below it the same size it always measured.
///
/// A third constant, <c>TerminalTabsHeight</c>, stood beside these two through v5b's chrome wave: the
/// window-wide tab strip that used to sit above every screen, 42 pixels, whether or not there were any
/// tabs to draw. v5b's session-shell wave retires that strip — see <c>MainWindow.axaml</c>'s own remark
/// on where a session's tabs live now — and with it the constant: <see cref="ScreenHeight"/> no longer
/// subtracts anything for a row that no longer exists as chrome above every screen. The tab row itself
/// is now inside the two screens that carry one, at its own 38-pixel height; see
/// <see cref="SessionTabRowHeight"/>, which only those two screens' own budgets pay.
/// </remarks>
internal const double TitleBarHeight = 44;
internal const double TitleBarHeight = 53;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>The v5b session shell's own tab row, from <c>App.axaml</c>'s <c>Button.sesstab</c> rule.</summary>
/// <remarks>
/// Not part of <see cref="ScreenHeight"/>'s budget, unlike the retired window-wide strip this replaced:
/// only the terminal and SFTP surfaces pay it, out of their own 26-pixel padded column — see
/// <see cref="SessionShellPadding"/> — rather than every screen paying it as chrome. Stated here so a
/// test can hold <c>SessionTabRow</c> to it the same way <c>TheChromeIsTheHeightTheBudgetAssumes</c>
/// holds the titlebar and the status bar to theirs.
/// </remarks>
internal const double SessionTabRowHeight = 38;
/// <summary>The v5b session shell's own padded column, from the design's <c>padding: 26px</c>.</summary>
internal const double SessionShellPadding = 26;
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
internal const double SessionSidebarWidth = 300;
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
internal const double SessionHeaderHeight = 60;
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
internal const double SessionStatusBarHeight = 37;
/// <summary>
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
/// The bordered container both session-shell screens sit inside, from <c>MainWindow.axaml</c>'s
/// <c>BorderThickness="1"</c> around the header/pane/status-bar column and the sidebar beside it.
/// </summary>
internal const double SessionShellBorderThickness = 1;
/// <summary>
/// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — <c>TransfersScreen</c>
/// today, and whatever sits in the terminal's own pane — actually gets once the session shell built in
/// wave B has taken its padding, its tab row, its header and its status bar. Wave B left
/// <c>MeasureConnectingAsync</c> and <c>MeasureHostKeyAsync</c> measuring at the roomier
/// <see cref="ScreenWidth"/>/<see cref="ScreenHeight"/> instead, with a remark on each admitting the gap;
/// this is what closes it.
/// </summary>
/// <remarks>
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
/// and went would move every screen up and down by 42 pixels each time the last tab closed — so this is
/// a fixed cost rather than a conditional one, and the budget can be a constant.
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
/// </remarks>
internal const double TerminalTabsHeight = 42;
internal static double SessionScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
- SessionHeaderHeight - SessionStatusBarHeight;
/// <summary>
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
/// column showing beside it.
/// </summary>
/// <remarks>
/// <see cref="ScreenWidth"/> less <see cref="SessionShellPadding"/> on both the left and the right of the
/// outer padded column, less <see cref="SessionShellBorderThickness"/> on both the left and the right of
/// the bordered container, less <see cref="SessionSidebarWidth"/> when the sidebar is showing beside the
/// pane rather than collapsed — see <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>, which for the
/// SFTP surface is exactly <c>Transfers.IsConnected</c>: the caller passes that fact in rather than this
/// harness guessing it, because it is a fact about a view model this file knows nothing about.
/// </remarks>
internal static double SessionScreenWidth(bool sidebarVisible) =>
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness)
- (sidebarVisible ? SessionSidebarWidth : 0);
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
/// <remarks>
@@ -111,12 +178,17 @@ internal static class LayoutHarness
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <summary>The height a screen actually gets at the window's minimum.</summary>
/// <summary>
/// The height a full-bleed page screen actually gets at the window's minimum.
/// </summary>
/// <remarks>
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
/// Equal to <see cref="ContentHeight"/> since v5b's session-shell wave retired the window-wide tab strip
/// that used to be subtracted here — see <see cref="TitleBarHeight"/>'s own remark. The terminal and
/// SFTP surfaces pay for their own tab row, header and status bar out of their own budget now, which
/// this constant does not describe; a test measuring either of those two screens has to account for the
/// session shell's own geometry rather than reading it off this property.
/// </remarks>
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
internal static double ScreenHeight => ContentHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
@@ -0,0 +1,338 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
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>
/// 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 land on the screen they promise, and shut the popover.</summary>
/// <remarks>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
/// </remarks>
[Fact]
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
[Fact]
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
[Fact]
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, label), window);
shell.Screen.ShouldBe(target);
shell.IsShowingPages.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
});
/// <remarks>
/// Through Preferences rather than a direct <c>SignOutCommand</c> — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card the mock has no
/// room for at all is drawn inline on that one screen 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_GoesToPreferencesAndArmsTheSignOutConfirmation()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, "Logout"), window);
shell.Screen.ShouldBe(ShellScreen.Preferences);
shell.IsConfirmingSignOut.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
});
}
// ---- 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);
}
}
@@ -825,7 +825,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
// usage is "deliberately not given the session shell above."
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
}
/// <remarks>
@@ -1144,18 +1147,30 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
// ---- The transfers screen ----
//
// ◆ WAVE C's OWN BUDGET. This screen carries two real widths, not one, because the same TransfersScreen
// control sits in two different real containers — see LayoutHarness.SessionScreenWidth's own remark and
// MainWindow.axaml. On SFTP it is inside wave B's session shell: a 26px padded column, a 1px bordered
// container, and — once Transfers.IsConnected — a 300px QUICK ACCESS sidebar squeezed in beside it. On S3
// it is the plain screen it always was, at the roomier LayoutHarness.ScreenWidth/ScreenHeight budget; see
// TheS3ScreenFitsWithNoBucketsToOpen and MeasureBucketsAsync. Every test below this point measures the
// SFTP usage through MeasureTransfersAsync, which reads transfers.IsConnected itself to decide whether
// the sidebar is squeezing the pane — the same fact ShowsQuickAccessSidebar reads.
/// <remarks>
/// <para>
/// The widest thing in this window and the one with the least room to give: two file listings side by
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
/// a button and a chip on one line.
/// The widest this screen gets and the one with the least room to give once connected: two file listings
/// side by side, a 64-pixel arrow column between them, and a queue underneath — inside 472 pixels once
/// the session shell's own padding, border and QUICK ACCESS sidebar have all been taken out. That leaves
/// 204 pixels a side, which is the width every restyled row template in <c>TransfersScreen.axaml</c> was
/// actually chosen against; see its own remark on the column widths.
/// </para>
/// <para>
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
/// of real length rather than with fixtures chosen to fit.
/// Measured disconnected here, though, which is the state the screen opens in and the one where the
/// local pane is at its fullest it lists this machine's home directory, so the row template is
/// exercised with real names of real length rather than with fixtures chosen to fit. Disconnected also
/// means no sidebar yet, so this particular test is measured at the roomier 772-pixel shape;
/// <see cref="TheTransfersScreenFitsWithASessionOpen"/> below is the one that reaches the 472-pixel one.
/// </para>
/// </remarks>
[Fact]
@@ -1253,14 +1268,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
/// that already carries UP, REFRESH and DELETE.
/// The state the screen is in once something is open, and the tightest one wave C's restyle has to
/// survive: <c>Transfers.IsConnected</c> is exactly what pulls <c>SessionSidebar</c> into view beside
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
/// thing squeezing DISCONNECT off the edge at this width.
/// </para>
/// <para>
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
/// The address is a long one deliberately, because <c>SessionAddress</c> still has to hold it without
/// trimming where the header prints it.
/// </para>
/// </remarks>
[Fact]
@@ -1303,6 +1322,214 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
/// <remarks>
/// The row template's own SIZE and MODIFIED columns, proved against real bindings rather than against
/// the fixture's own home directory — whose contents this suite does not control — by adding one row of
/// each pane's own shape directly and reading the rendered <c>TextBlock</c>s back. PERMS stays remote-only,
/// which is what <see cref="RemoteEntryRowViewModel"/> carrying it and <see cref="LocalEntryRowViewModel"/>
/// not proves alongside the two shared columns.
/// </remarks>
[Fact]
public async Task TheRowTemplateShowsSizeAndDateColumns()
{
var local = new LocalEntryRowViewModel(new LocalEntry(
"notes.txt",
Path.Combine(Path.GetTempPath(), "notes.txt"),
IsDirectory: false,
Length: 4_096,
new DateTimeOffset(2026, 7, 21, 9, 0, 0, TimeSpan.Zero)));
// Cleared first, not merely appended to: this fixture's real home directory can hold far more entries
// than the pane's own viewport, and a virtualizing ListBox only realises the rows that fit in it. A
// row added after all of those would never be built at all, which is a false pass rather than a
// proof — the assertion below would find nothing to have missed.
transfers.LocalEntries.Clear();
transfers.LocalEntries.Add(local);
var remote = new RemoteEntryRowViewModel(new SftpEntry(
"deploy.log",
"/srv/releases/deploy.log",
SftpEntryKind.File,
49_152,
new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.Zero),
"-rw-r--r--"));
transfers.RemoteEntries.Add(remote);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var texts = screen.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text).ToList();
texts.ShouldContain(local.Size, customMessage: "the local pane's own SIZE column");
texts.ShouldContain(local.Modified, customMessage: "the local pane's own MODIFIED column");
texts.ShouldContain(remote.Size, customMessage: "the remote pane's own SIZE column");
texts.ShouldContain(remote.Modified, customMessage: "the remote pane's own MODIFIED column");
texts.ShouldContain(remote.Permissions, customMessage: "PERMS, which stays remote-only");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// <see cref="TransferRowViewModel.StatusWord"/> is what v5b's TRANSFERS strip prints in place of the old
/// all-caps <c>StateLabel</c> chip — a live percentage while running, a plain word otherwise — and this
/// holds the mapping against every state <c>FileTransferQueue</c> actually reports, at the view model and
/// rendered onto the strip itself.
/// </remarks>
[Fact]
public async Task TheTransferStripsStatusWordsMapTheQueuesRealStates()
{
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
Enqueue(TransferDirection.Download, "running.bin", 1_000, 640, TransferState.Running, bytesPerSecond: 1_000);
Enqueue(TransferDirection.Upload, "done.txt", 1_000, 1_000, TransferState.Completed);
Enqueue(TransferDirection.Upload, "stopped.txt", 1_000, 200, TransferState.Cancelled);
Enqueue(TransferDirection.Upload, "failed.txt", 1_000, 0, TransferState.Failed, failure: "refused");
var expected = new[] { "queued", "64%", "done", "stopped", "failed" };
transfers.Transfers.Select(row => row.StatusWord).ShouldBe(expected);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var words = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.Classes.Contains("transferstatus"))
.Select(text => text.Text ?? string.Empty)
.ToList();
words.ShouldBe(expected);
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The TRANSFERS strip's own "collapse" — not to zero, but down to the honest sentence the full strip
/// carried when nothing had ever been queued. Both shapes are checked: the header and its count chip are
/// absent with nothing queued, and a queued transfer brings the full strip straight back.
/// </remarks>
[Fact]
public async Task TheTransfersStripCollapsesWhenTheQueueIsEmpty()
{
transfers.Transfers.ShouldBeEmpty("the fixture queues nothing before this test adds any");
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeFalse("the full strip collapses with nothing queued");
var collapsed = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Text is { } spoken
&& spoken.StartsWith("Nothing queued.", StringComparison.Ordinal));
collapsed.IsEffectivelyVisible.ShouldBeTrue(
"the honest sentence stays reachable in the collapsed shape");
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeTrue("a queued transfer brings the full strip back");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
/// pane.
/// </remarks>
[Fact]
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
{
transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"deploy.sh", "/srv/releases/site/deploy.sh", SftpEntryKind.File,
912, TimeProvider.System.GetUtcNow(), "-rwxr-xr-x")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"shared", "/srv/releases/site/shared", SftpEntryKind.Directory,
0, TimeProvider.System.GetUtcNow(), string.Empty)));
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
TransferState.Running, bytesPerSecond: 6_500_000);
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
TransferState.Cancelled);
await MeasureTransfersAsync(
faults => faults.ShouldBeEmpty("connected, with populated rows and a full queue at once"));
}
// ---- The chrome ----
/// <remarks>
@@ -1355,27 +1582,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
/// the entries have to leave room for each other, which is the same failure the old four-button selector
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
/// pass every other assertion here.
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
/// than one: the switcher's three segments, each a third of the rail's own content width; the six item
/// rows below it and the user chip at the foot, both the rail's full content width. A single
/// across-the-board width assertion the way the v3 version of this test made one would either be wrong
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
/// own width now.
/// </para>
/// <para>
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
/// in. See <c>NavRail.axaml</c>.
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
/// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
/// entry silently dropping off the bottom would still pass every other assertion here.
/// </para>
/// </remarks>
[Fact]
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail();
var rail = new NavRail { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
@@ -1383,18 +1610,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
var segments = buttons.Where(button => button.Classes.Contains("navseg")).ToList();
var rows = buttons.Where(button => button.Classes.Contains("nav")
&& !button.Classes.Contains("navseg")).ToList();
var chip = buttons.Single(button => button.Classes.Contains("navuser"));
foreach (var button in buttons)
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
rows.Count.ShouldBe(
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
foreach (var segment in segments)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
segment.Bounds.Height.ShouldBeGreaterThan(20);
}
// 190 wide, less the divider down the rail's right edge, less the 8 of inset on
// each side that v2 gives the rows so a filled one reads as a rounded row rather
// than as a full-width band. Stated exactly rather than as a lower bound: a button
// that stopped filling the row would leave a dead strip beside a destination, which
// is precisely the kind of near-miss a bound hides.
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 16);
foreach (var row in rows.Append(chip))
{
row.Bounds.Height.ShouldBeGreaterThan(20);
// 255 wide, less the 1-pixel border down the rail's right edge, less the 14 of
// inset v5b's own padding gives each side. Stated exactly rather than as a lower
// bound: a row that stopped filling the width would leave a dead strip beside a
// destination, which is precisely the kind of near-miss a bound hides.
row.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 28);
}
LayoutHarness.Unreachable(window).ShouldBeEmpty();
@@ -1579,8 +1817,21 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
/// <remarks>
/// The same width and height as a full screen: the card is a sibling of the page area rather than
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
/// <para>
/// ◆ WAVE C's OWN FIX. This used to measure a full screen's width and height, which is roomier than what
/// v5b's own session shell actually leaves the card once its padding, tab row, header and sidebar are
/// taken out; the gap is closed here rather than merely admitted. <c>ConnectingCard</c> sits in the same
/// <c>Grid.Column="0"</c> pane <c>ConnectingPane</c> occupies in <c>MainWindow.axaml</c>'s terminal usage
/// — the sidebar is <c>Grid.Column="1"</c>, a true sibling rather than an overlay on top of this one — so
/// its real rectangle is <see cref="LayoutHarness.SessionScreenWidth"/>/<see cref="LayoutHarness.SessionScreenHeight"/>,
/// not the plain screen budget.
/// </para>
/// <para>
/// The sidebar is showing whenever this card can be, which is what makes the width unconditional rather
/// than a parameter here: <c>ShowsQuickAccessSidebar</c> on the terminal surface is
/// <c>SelectedTab is not null</c>, and a connecting card has nothing to show without a selected tab
/// either — see the two callers below, both of which select one before calling this.
/// </para>
/// </remarks>
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1594,7 +1845,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
card, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
@@ -1609,12 +1860,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks>
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
/// stop fitting when the strip is what it is drawn over.
/// <para>
/// ◆ WAVE C's OWN FINDING, rather than wave C's own fix: this one turned out not to need the narrower
/// budget wave B's remark predicted. <c>HostKeyCard</c> is not nested inside either session shell at all
/// — <c>MainWindow.axaml</c> draws it as a sibling of the whole unlocked <c>Grid</c> (nav rail and content
/// both), the last child before the titlebar/status-bar row, so its own scrim genuinely spans the full
/// content area rather than the narrower column either session shell leaves. "Both cover the rectangle
/// the terminal would be in" — <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>'s own remark — describes
/// the intent the two states share, not this control's actual bounds.
/// </para>
/// <para>
/// <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> — the nav rail already
/// taken out, the session shell's own padding and sidebar not — stays the right measurement rather than
/// the wrong one wave B's remark called it: it is narrower than what this control truly gets (the nav
/// rail's own 255 pixels back), so a card proven to fit here is proven to fit the real, wider overlay too.
/// <c>Border.card</c>'s own 520-pixel <c>MaxWidth</c> means neither number was ever the risk; what this
/// records is that no gap was left for a later wave to close.
/// </para>
/// </remarks>
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1852,7 +2114,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
/// <summary>Lays the SFTP usage of <c>TransfersScreen</c> out at the real budget wave B's session shell leaves.</summary>
/// <remarks>
/// <c>transfers.IsConnected</c> decides the width the same way <c>ShowsQuickAccessSidebar</c> does at
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
/// </remarks>
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen,
LayoutHarness.SessionScreenWidth(transfers.IsConnected),
LayoutHarness.SessionScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
/// <remarks>
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
/// the same budget every other full-bleed page gets.
/// </remarks>
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
@@ -1896,8 +2193,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks>
/// <para>
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
/// takes 190 and the vault list 268, leaving 558 for everything above.
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
/// widened the rail, because the minimum grew by exactly what the rail did.
/// </para>
/// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
@@ -0,0 +1,391 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
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 v5b in-screen tab row answers a pointer, on both of the two ways it is used.
/// </summary>
/// <remarks>
/// The replacement for <c>TerminalTabsTests</c>, which measured the window-wide strip this control replaced
/// — see <c>SessionTabRow.axaml</c>'s own remark for why one control now serves both the terminal surface and
/// the SFTP surface. The gestures that do not vary by surface — middle-click closes, "+" opens the palette,
/// the tab pointer feedback — are carried over from that suite essentially unchanged; what is new here is
/// <see cref="TabRowTabClickRunsTheGivenCommand"/> and <see cref="SftpRowMarksTheColourItIsToldTo"/>, which
/// prove the one thing this control adds: the click and the accent colour are handed in rather than fixed.
/// </remarks>
public sealed class SessionTabRowTests : 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($"session-tabs-{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();
}
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
/// <remarks>
/// The terminal row's own click: <c>TabCommand</c> bound to <c>SelectTabCommand</c>, the same as the
/// window-wide strip's used to be.
/// </remarks>
[Fact]
public async Task ALeftClickOnATabRunsTheBoundTabCommand()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// The one thing this control adds over the strip it replaces: which command a tab click runs is handed
/// in, not fixed. Proved with a command that is not <c>SelectTabCommand</c> at all, so a row that quietly
/// ignored <c>TabCommand</c> and fell back to selecting the tab itself would fail this rather than pass
/// it by coincidence.
/// </remarks>
[Fact]
public async Task TabRowTabClickRunsTheGivenCommand()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
var seen = new List<TerminalTabViewModel>();
var strip = new SessionTabRow
{
DataContext = shell,
TabCommand = new RelayCommand<TerminalTabViewModel>(tab => seen.Add(tab!)),
};
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
var button = TabButton(strip, shell.Tabs[0]);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
seen.ShouldHaveSingleItem().ShouldBe(shell.Tabs[0]);
shell.SelectedTab.ShouldBeNull("the given command ran instead of SelectTabCommand");
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <remarks>
/// The strip's own height budget moved with it — see <see cref="LayoutHarness.SessionTabRowHeight"/> —
/// but the invariant this test protects is the same one <c>TerminalTabsTests</c> protected: a row of
/// tabs must not grow the chrome around it, however many are open.
/// </remarks>
[Fact]
public async Task TheRowIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
strip.DesiredSize.Height.ShouldBe(LayoutHarness.SessionTabRowHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered —
/// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads
/// identically to the plus at rest by design. This proves the same two facts a different way: an inactive
/// tab still changes under the pointer, and the plus never gets the treatment an <em>active</em> tab does
/// — the coloured top border and the DeepChrome fill — which is the one visual claim "not a thing being
/// chosen between" actually has to hold.
/// </remarks>
[Fact]
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsAnActiveTab()
{
await OnTheStripAsync(
(strip, window) =>
{
var tab = TabButton(strip, shell.Tabs[0]);
var resting = Fill(tab);
window.MouseMove(Centre(tab, window));
LayoutHarness.Settle(window, 900, 600);
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
Fill(tab).ShouldNotBe(
resting,
"a tab that does not change under the pointer is one nobody can tell is clickable");
window.MouseMove(new Point(0, 0));
LayoutHarness.Settle(window, 900, 600);
shell.SelectTabCommand.Execute(shell.Tabs[0]);
LayoutHarness.Settle(window, 900, 600);
var activeTab = TabButton(strip, shell.Tabs[0]);
var plus = PlusButton(strip);
Fill(plus).ShouldNotBe(
Fill(activeTab),
"the button that opens a connection never carries the active tab's DeepChrome fill");
Presenter(plus).BorderThickness.ShouldBe(
default(Thickness),
"it carries no outline, because it is not a thing being chosen between");
});
}
/// <remarks>
/// The SFTP row's own accent: <c>Classes="sftp"</c> on this control's own usage — see
/// <c>MainWindow.axaml</c> — is what selects <c>Magenta</c> over the terminal row's <c>TerminalTabAccent</c>
/// for an active tab's top border. Read as a colour rather than a class list, so this catches the App.axaml
/// selector actually resolving to a different brush rather than merely having the class applied.
/// </remarks>
[Fact]
public async Task SftpRowMarksTheColourItIsToldTo()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.SelectTabCommand.Execute(shell.Tabs[0]);
var sshRow = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var sftpRow = new SessionTabRow
{
DataContext = shell,
TabCommand = shell.SelectTabCommand,
Classes = { "sftp" },
};
var sshWindow = new Window { Content = sshRow };
var sftpWindow = new Window { Content = sftpRow };
LayoutHarness.Settle(sshWindow, 900, 600);
LayoutHarness.Settle(sftpWindow, 900, 600);
try
{
var sshBorder = Presenter(TabButton(sshRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
var sftpBorder = Presenter(TabButton(sftpRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
sshBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#7C5CFF"), "TerminalTabAccent");
sftpBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#DD1296"), "Magenta");
}
finally
{
sshWindow.Close();
sftpWindow.Close();
}
},
Token);
}
// ---- Helpers ----
private static ContentPresenter Presenter(Visual button) =>
button.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(presenter => presenter.Name is "PART_ContentPresenter");
private static Color? Fill(Visual button) =>
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
private Task OnTheStripAsync(Action<SessionTabRow, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("sesstab"));
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
private static Point Centre(Visual control, Visual window) =>
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");
}
@@ -1,507 +0,0 @@
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 tab strip answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one
/// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a
/// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and
/// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a
/// binding.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the reason the palette's suite is one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. No vault and no session — the strip
/// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so
/// they can be put there directly. Closing one asks the workspace to end a session it has never heard of,
/// which the workspace answers by returning: that is the same path a real close takes, minus a shell.
/// </para>
/// </remarks>
public sealed class TerminalTabsTests : 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($"tabs-{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>())
{
// The only state the strip is ever interactive in. Assigned rather than reached through an
// enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes
/// one, and the strip answered nothing but a left click before.
/// </remarks>
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The other half of the rule, and the reason the handler is on the tab's own template root rather than
/// on the strip: a middle click on the chrome between the last tab and the edge of the window must not
/// close anything. Wiring it on the strip and testing what was underneath the pointer would have been
/// the same feature with a way to get it wrong.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
// Well right of two short tabs and the button after them, and inside the strip's own height.
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
/// <remarks>
/// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One
/// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The one assumption the nested-button template makes, stated as a test. Avalonia's
/// <c>Button.OnPointerPressed</c> takes the capture and marks a left press handled, so the cross does
/// not also reach the tab underneath it — which would select a tab on its way out and leave the
/// terminal switching to something that is about to disappear.
/// </remarks>
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing.
/// See the note in <c>TerminalTabs.axaml</c> for why a flyout over the terminal's rectangle is not a
/// claim this project is willing to make without a screenshot.
/// </remarks>
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <summary>
/// The three fixed tabs select what they name, and Vaults comes back to the page it was left on.
/// </summary>
/// <remarks>
/// <para>
/// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with
/// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent:
/// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on
/// Hosts, which is most of the time.
/// </para>
/// <para>
/// Driven through the strip rather than through the commands, because what is being checked is that
/// three buttons in the markup are wired to three different things. Three commands called directly
/// would pass on a strip whose SFTP tab was bound to the S3 one.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage()
{
await OnTheStripAsync((strip, window) =>
{
shell.ShowScreenCommand.Execute(ShellScreen.Snippets);
shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab");
Click(FixedTab(strip, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time");
Click(FixedTab(strip, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Click(FixedTab(strip, "Vaults"), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.Screen.ShouldBe(
ShellScreen.Snippets,
"the Vaults tab comes back to the page it was left on, not to Hosts");
});
}
/// <remarks>
/// The caret is the second half of the Vaults pill and the only control in this strip that opens a
/// popup. Asserted as behaviour rather than as markup, because what makes it correct is the order in
/// the handler rather than the flyout being attached — see the test below.
/// </remarks>
[Fact]
public async Task TheCaretBesideVaults_OpensTheVaultMenu()
{
await OnTheStripAsync((strip, window) =>
{
var caret = CaretButton(strip);
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
Click(caret, window);
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeTrue();
});
}
/// <summary>
/// Opening the vault menu selects the Vaults tab first, so the renderer is collapsed under it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The occlusion guard, and the reason this strip may have a flyout at all.</b> The comment on the
/// <c>+</c> button refuses one because a popup dropping into the terminal's rectangle would have to
/// composite above a native child window, which this project does not claim without a screenshot. The
/// caret sidesteps the question rather than answering it: it goes to the Vaults tab before it opens,
/// and a page surface is one where the renderer is not drawn.
/// </para>
/// <para>
/// So the assertion is on <see cref="MainWindowViewModel.IsTerminalShowing"/> rather than on anything
/// about the popup. A change that opened the flyout without moving the surface first would still show a
/// menu in every screenshot anybody took on a machine where it happened to work.
/// </para>
/// </remarks>
[Fact]
public async Task OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt()
{
await OnTheStripAsync((strip, window) =>
{
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.IsTerminalShowing.ShouldBeTrue("this test is meaningless without one in the way");
Click(CaretButton(strip), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse(
"the flyout must never have to composite over the renderer's native child window");
});
}
/// <remarks>
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
/// SFTP.
/// <para>
/// The Vaults caret is a sibling of its tab rather than a child, which is what keeps this assertion
/// meaning what it says: a button inside a fixed tab would still be a close box.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsCarryNoCloseBox()
{
await OnTheStripAsync((strip, _) =>
{
foreach (var label in new[] { "Vaults", "SFTP", "S3" })
{
FixedTab(strip, label)
.GetVisualDescendants()
.OfType<Button>()
.ShouldBeEmpty($"{label} is a destination, not a session");
}
});
}
/// <remarks>
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
/// and this is what stops the budget drifting from the markup. See
/// <see cref="LayoutHarness.TerminalTabsHeight"/>.
/// </remarks>
[Fact]
public async Task TheStripIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new TerminalTabs { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// What it asks for, not what this host window gave it. Hosting it at 34 and then
// asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is
// exactly the regression the budget needs catching.
strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <summary>
/// A tab lights under the pointer, and the button that opens one is not drawn as a tab.
/// </summary>
/// <remarks>
/// <para>
/// The only test in this suite that reads a brush rather than a rectangle, and it is here because that
/// was the gap a real regression went through. Everything else measures heights and reachability, so a
/// strip whose tabs had silently stopped answering the pointer passed all of it.
/// </para>
/// <para>
/// What went wrong is worth stating, because the shape of it will recur. Avalonia has no specificity —
/// the later declaration wins — and when the tab became a pill that paints its own background, that
/// background was declared *after* the hover rule it relied on and after the exceptions the <c>+</c>
/// is made of. So every tab lost its pointer feedback and the <c>+</c> gained a fill and an outline it
/// is specifically not supposed to have. Both are one assertion each below.
/// </para>
/// </remarks>
[Fact]
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsATab()
{
await OnTheStripAsync(
(strip, window) =>
{
var tab = TabButton(strip, shell.Tabs[0]);
var resting = Fill(tab);
window.MouseMove(Centre(tab, window));
LayoutHarness.Settle(window, 900, 600);
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
Fill(tab).ShouldNotBe(
resting,
"a tab that does not change under the pointer is one nobody can tell is clickable");
// Off the strip again, so the plus is measured at rest rather than under the pointer.
window.MouseMove(new Point(0, 0));
LayoutHarness.Settle(window, 900, 600);
var plus = PlusButton(strip);
Fill(plus).ShouldNotBe(
Fill(TabButton(strip, shell.Tabs[0])),
"the button that opens a connection is not one of the connections");
Presenter(plus).BorderThickness.ShouldBe(
default(Thickness),
"it carries no outline, because it is not a thing being chosen between");
});
}
// ---- Helpers ----
/// <summary>The presenter the Fluent theme actually paints, which is where every button style lands.</summary>
private static ContentPresenter Presenter(Visual button) =>
button.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(presenter => presenter.Name is "PART_ContentPresenter");
/// <remarks>
/// The colour rather than the brush. Two <see cref="ISolidColorBrush"/> instances holding the same
/// colour are not equal, and it is the colour a user sees.
/// </remarks>
private static Color? Fill(Visual button) =>
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
/// <summary>Two open tabs, laid out in a window the width the application's is.</summary>
private Task OnTheStripAsync(Action<TerminalTabs, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new TerminalTabs { DataContext = shell };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Found by the class the style system already keys on, rather than by position in the visual tree: the
/// template puts the cross inside the tab, so both buttons carry the same data context and only the
/// classes tell them apart.
/// </remarks>
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("tab"));
/// <inheritdoc cref="TabButton" />
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
/// <summary>The half of the Vaults pill that opens the vault menu.</summary>
/// <inheritdoc cref="TabButton" path="/remarks" />
private static Button CaretButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("caret"));
/// <inheritdoc cref="TabButton" />
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
/// <summary>One of the three tabs that are always there, found by the word on it.</summary>
/// <remarks>
/// By its label rather than by its position in the strip, so that adding a fourth or reordering the
/// three does not silently point these tests at the wrong one. The class narrows it to a fixed tab
/// first, because a terminal tab could be opened on a host called SFTP.
/// </remarks>
private static Button FixedTab(Visual strip, string label) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("fixed")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static void Click(Visual control, Window window)
{
var at = Centre(control, window);
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
private static Point Centre(Visual control, Visual window) =>
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");
}
@@ -0,0 +1,104 @@
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 titlebar's own search pill still opens the quick-connect palette after v5b's redraw.
/// </summary>
/// <remarks>
/// A narrow suite on purpose: <see cref="LayoutHarnessTests"/> already holds the bar to its declared
/// height and proves nothing inside it is unreachable, and the search pill's own accent-on-hover border is
/// a style rule with nothing to assert headlessly. What is worth a gesture is the one thing a fidelity pass
/// could quietly break without any layout test noticing — the pill no longer opening what Ctrl+K opens.
/// </remarks>
public sealed class TitleBarTests : 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($"titlebar-{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>())
{
// The search pill is disabled while locked — see TitleBar.axaml's IsEnabled binding — and this
// is the only state that gesture is reachable in.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var bar = new TitleBar { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
shell.IsSearching.ShouldBeFalse("nothing has been pressed yet");
var pill = bar.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("search"));
var centre = pill.TranslatePoint(
new Point(pill.Bounds.Width / 2, pill.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the pill is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
shell.IsSearching.ShouldBeTrue(
"the pill is the click-through path Ctrl+K also opens — see ToggleSearchCommand");
}
finally
{
window.Close();
}
},
Token);
}
}
+362 -14
View File
@@ -2789,6 +2789,125 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("host");
}
/// <remarks>
/// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono
/// count beside every category row, and this pins that each one is the same number the category's own
/// list already carries rather than a second, hand-kept tally that could drift from it.
/// </remarks>
[Fact]
public async Task TheCategoryRailCountsMatchTheUnderlyingLists()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddKeyAsync(vault, "backup");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
await AddTagAsync(vault, "production");
vault.Keys.Count.ShouldBe(2);
vault.Credentials.Count.ShouldBe(1);
vault.Tags.Count.ShouldBe(1);
vault.ObjectStores.Count.ShouldBe(0);
vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included");
}
/// <remarks>
/// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a
/// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance —
/// which is the whole reason the column exists.
/// </remarks>
[Fact]
public async Task EachVaultItemKindCarriesItsOwnGlyph()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
await AddTagAsync(vault, "production");
vault.ShowSectionCommand.Execute(VaultSection.All);
var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph);
byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty();
byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty();
byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty();
new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] }
.Distinct(StringComparer.Ordinal).Count()
.ShouldBe(3, "three kinds on the same table read as three different glyphs");
}
/// <remarks>
/// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had
/// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and
/// clearing it brings every row straight back.
/// </remarks>
[Fact]
public async Task TheItemFilterNarrowsTheMergedTableByNameOrType()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy-key");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ItemFilter = "deploy";
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]);
vault.ItemFilter = "PASSWORD";
vault.VaultItems.Select(row => row.Name).ShouldBe(
["pg-primary"], "the type word matches too, case-insensitively");
vault.ItemFilter = string.Empty;
vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion");
}
/// <remarks>
/// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all
/// three read the same resolved-binding scan <c>HostsBoundTo</c> already used for the deletion warning,
/// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of
/// them going stale relative to the others.
/// </remarks>
[Fact]
public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-api-01");
await AddKeyAsync(vault, "deploy");
var key = vault.Keys[0];
await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId);
vault.ShowSectionCommand.Execute(VaultSection.All);
var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key);
row.UsedBySummary.ShouldBe("prod-api-01");
row.HasUsedBySummary.ShouldBeTrue();
vault.SelectedVaultItem = row;
vault.HasSelectedItemUsedByHosts.ShouldBeTrue();
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01");
vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host");
// And a key nothing authenticates with says none of this — never a zero-count claim.
await AddKeyAsync(vault, "unused");
vault.SelectedVaultItem = vault.VaultItems.Single(
item => string.Equals(item.Name, "unused", StringComparison.Ordinal));
vault.HasSelectedItemUsedByHosts.ShouldBeFalse();
vault.SelectedItemInUseSummary.ShouldBe(string.Empty);
}
// ---- Keeping an open editor's pickers in step with the vault ----
/// <remarks>
@@ -5639,7 +5758,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.Status.ShouldContain("password");
}
// ---- The terminal pin strip ----
// ---- The v5b session sidebar's QUICK ACCESS ----
/// <summary>
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
@@ -5666,16 +5785,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
[Fact]
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
public async Task TheSidebar_ShowsTheConnectedTabsHostsPins()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
}
[Fact]
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
public async Task TheSidebar_StaysHiddenBeforeAnythingConnects()
{
var vault = await ReadyToConnectAsync();
@@ -5684,14 +5803,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
// that happens to be selected on the hosts screen.
shell.ShowsPinStrip.ShouldBeFalse();
// Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed
// to a selected tab, not to the host that happens to be selected on the hosts screen.
shell.ShowsQuickAccessSidebar.ShouldBeFalse();
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
/// <remarks>
/// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar
/// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on
/// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty
/// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did.
/// </remarks>
[Fact]
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins()
{
var vault = await ReadyToConnectAsync();
@@ -5703,28 +5828,45 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing");
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
shell.ShowsQuickAccessSidebar.ShouldBeFalse(
"a page is showing, not the terminal or SFTP the sidebar sits beside");
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected");
}
/// <remarks>
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
/// The other surface the sidebar draws on since v5b: SFTP, gated on <c>Transfers.IsConnected</c> rather
/// than on a selected tab, since a session on that screen is its own connection — see
/// <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>.
/// </remarks>
[Fact]
public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.IsTransfersShowing.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now");
}
/// <remarks>
/// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
@@ -5746,6 +5888,129 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
}
// ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ----
/// <remarks>
/// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses —
/// see the deviation recorded on <c>MainWindowViewModel.SelectFilesHostCommand</c>.
/// </remarks>
[Fact]
public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected()
{
await ConnectedHostWithAPinAsync();
var tab = shell.Tabs[0];
shell.SelectedTab = null;
await shell.SelectFilesHostCommand.ExecuteAsync(tab);
shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected");
shell.IsTransfersShowing.ShouldBeTrue();
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.Transfers.IsConnected.ShouldBeTrue();
}
/// <remarks>
/// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface
/// directions the v5b notes ask for, through <c>VaultViewModel.ConnectCommand</c> rather than through a
/// tab that does not exist.
/// </remarks>
[Fact]
public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.Transfers.IsConnected.ShouldBeTrue();
var tabsBefore = shell.Tabs.Count;
await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null);
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host");
shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
/// that same mechanism rather than reimplementing it.
/// </remarks>
[Fact]
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
{
await ConnectedHostWithAPinAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
var row = snippets.Visible.ShouldHaveSingleItem();
await shell.InsertSnippetCommand.ExecuteAsync(row);
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
}
[Fact]
public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen()
{
await UnlockedAsync();
shell.AddSnippetFromSidebarCommand.Execute(null);
shell.IsSnippetsShowing.ShouldBeTrue();
shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue();
}
/// <remarks>
/// The closest honest affordance the v5b notes ask for: this application cannot open a host editor
/// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as
/// the hosts screen's own EDIT does.
/// </remarks>
[Fact]
public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor()
{
await ConnectedHostWithAPinAsync();
var vault = shell.Vault!;
shell.PinFolderFromSidebarCommand.Execute(null);
shell.IsHostsShowing.ShouldBeTrue();
vault.IsEditing.ShouldBeTrue();
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
/// <remarks>
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
/// opens, and this reads it back through the same clock.
/// </remarks>
[Fact]
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
{
await ConnectedHostWithAPinAsync();
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
}
/// <remarks>
/// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than
/// a blank or a placeholder standing in for them.
/// </remarks>
[Fact]
public async Task SessionFacts_AreAbsentWithNoSessionOpen()
{
await UnlockedAsync();
shell.IsSessionConnected.ShouldBeFalse();
shell.SessionAddress.ShouldBeNull();
shell.SessionElapsedText.ShouldBeNull();
}
[Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
{
@@ -6593,6 +6858,40 @@ public sealed class ShellFlowTests : IAsyncLifetime
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
}
/// <remarks>
/// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see
/// <c>SnippetsViewModel.RequestDelete</c>'s own remarks for why the two are separate commands. Pins the
/// two-step shape every other keychain item's deletion already has: arming changes nothing, and only
/// confirming actually removes it.
/// </remarks>
[Fact]
public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed()
{
await UnlockedAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false);
snippets.Selected = snippets.Visible.Single();
snippets.RequestDeleteCommand.Execute(null);
snippets.IsConfirmingDelete.ShouldBeTrue();
snippets.DeleteQuestion.ShouldContain("restart the api");
snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place");
snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself");
snippets.CancelDeleteCommand.Execute(null);
snippets.IsConfirmingDelete.ShouldBeFalse();
snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was");
snippets.RequestDeleteCommand.Execute(null);
await snippets.ConfirmDeleteCommand.ExecuteAsync(null);
snippets.IsConfirmingDelete.ShouldBeFalse();
snippets.Visible.ShouldBeEmpty();
}
private static SnippetsViewModel SnippetsOver(
VaultViewModel vault,
InsertTarget target,
@@ -6646,6 +6945,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null);
}
// ---- Logs ----
/// <remarks>
/// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own
/// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins
/// that <c>LogSection</c> genuinely has only the two values the segments name, and that the two flags a
/// segment's own active state reads are mutually exclusive.
/// </remarks>
[Fact]
public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections()
{
await UnlockedAsync();
var logs = shell.LogsScreen.ShouldNotBeNull();
logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections");
logs.ShowsConnections.ShouldBeTrue();
logs.ShowsActivity.ShouldBeFalse();
logs.ShowSectionCommand.Execute(LogSection.Activity);
logs.ShowsActivity.ShouldBeTrue();
logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true");
logs.ShowSectionCommand.Execute(LogSection.Connections);
logs.ShowsConnections.ShouldBeTrue();
Enum.GetValues<LogSection>().Length.ShouldBe(2, "the design draws two segments and the enum has two");
}
/// <remarks>
/// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per-
/// section fact this type's header comment already states truthfully, and steps aside for a refresh
/// error when refreshing just produced one.
/// </remarks>
[Fact]
public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError()
{
await UnlockedAsync();
var logs = shell.LogsScreen.ShouldNotBeNull();
logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes");
logs.ShowSectionCommand.Execute(LogSection.Activity);
logs.HeaderStatusLine.ShouldBe("one row per write, per device");
logs.Status = "network unreachable";
logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact");
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
@@ -639,7 +639,13 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
vault.IsEditing.ShouldBeFalse(vault.Status);
}
/// <summary>Switches a vault off through the menu, as the tab strip does.</summary>
/// <summary>
/// Switches a vault off through <see cref="MainWindowViewModel.ToggleVaultCommand"/> — the command
/// behind a vault row in the rail's own user popover now, not the tab strip's old vault menu, which is
/// gone; see <c>NavRail.axaml</c>. This project is deliberately Avalonia-free, so what this file proves
/// is the command's own effect; that the popover's row is wired to call it is
/// <c>DodoSSH.Client.App.Layout.Tests.NavRailTests</c>' business.
/// </summary>
private async Task HideAsync(Guid vaultId)
{
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);