Public Access
352 lines
15 KiB
C#
352 lines
15 KiB
C#
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 enter settings mode on the page they promise, and shut the
|
|
/// popover behind them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
|
|
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
|
|
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
|
|
/// and "Preferences" are no longer the same click.
|
|
/// </para>
|
|
/// <para>
|
|
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
|
|
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
|
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
|
|
|
|
[Fact]
|
|
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
|
|
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
|
|
|
|
[Fact]
|
|
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
|
|
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
|
|
|
|
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
|
OnTheRailAsync((rail, window) =>
|
|
{
|
|
var chip = UserChip(rail);
|
|
Click(chip, window);
|
|
|
|
Click(PopoverRow(window, label), window);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(target);
|
|
shell.IsShowingPages.ShouldBeTrue();
|
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
|
|
});
|
|
|
|
/// <remarks>
|
|
/// Through the Account settings page rather than a direct <c>SignOutCommand</c> — see
|
|
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card is drawn inline on
|
|
/// that one page while the vault is unlocked, and arming it from anywhere else would be a card raised
|
|
/// nobody could see.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePopoversLogoutRow_EntersSettingsOnAccountAndArmsTheSignOutConfirmation()
|
|
{
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
var chip = UserChip(rail);
|
|
Click(chip, window);
|
|
|
|
Click(PopoverRow(window, "Logout"), window);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
|
|
shell.IsConfirmingSignOut.ShouldBeTrue();
|
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
}
|