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
@@ -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);
}
}