Public Access
Settings, Vaults and Preferences all opened the same full-window settings mode and differed only in which of its seven pages they landed on. SettingsNav, which lists every one of those pages including Vaults and Preferences, is on screen the whole time you are in there — so two of the three rows were deep links into a place the third one opens. They saved one click and cost a menu that read as though it offered three destinations. It was three rows for a reason that had stopped applying. Until v5c, "Settings" and "Preferences" were two doors onto the same bare Preferences screen, and splitting them was the fix for something real; Vaults arrived beside them when VaultsScreen became a settings page too. Folding those two back in is not a return to that state. They are pages now, with a rail of their own, and what a menu at the foot of the nav rail is for is getting into settings at all. Nothing became unreachable — both are one click deeper, from the rail that was always going to be the way to them. ◆ WHAT STAYS, AND WHY IT IS NOT THE SAME ARGUMENT. Logout stays and still lands on the Account page rather than on General, because its confirmation card is drawn inline on that one page while the vault is unlocked; arming it from anywhere else would raise a card nobody could see. The vault switches and New vault above the divider stay too — neither is a page of the settings mode, and the switches have no other home. ◆ THE ROWS ARE REMOVED FROM THE MARKUP, NOT UNWIRED. NavRailTests asserts their absence rather than only asserting that Settings still works, because the failure that guards against is silent: a row left in the markup with its handler deleted is a menu entry that does nothing when pressed, which reads as a broken application rather than a tidied menu. The helper it asserts through excludes the vault switches by their data context and not by their label — a vault is free to be called "Vaults", and a test that a menu entry is absent must not be defeatable by what somebody named a vault. Two facts go, one comes back plus the absence check; the layout suite is 166. Manual check 1.8 walked a reader through all three rows in turn and now walks them through the one, then through SettingsNav's seven — which is where the two went. design-import-gaps.md gains a v5c-5 section rather than an edit to the v5c table: what that table records is what shipped in that pass, and it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
421 lines
18 KiB
C#
421 lines
18 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Media;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// How the rail's own switcher, mode-dependent first row and user popover answer a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// v5b moved three things onto this control that used to be tested elsewhere or not at all: the SSH/SFTP/S3
|
|
/// choice that used to be the tab strip's own fixed tabs (see <c>TerminalTabsTests</c>, which used to hold
|
|
/// the equivalent of the first two facts below), the mode-dependent first row the design calls its own
|
|
/// <c>mode</c> prop, and the popover that replaced the strip's vault menu. This suite is this control's
|
|
/// counterpart to that one — a minimal shell with two tabs and no vault, for the reason
|
|
/// <c>TerminalTabsTests</c> gives: nothing here reads <c>Vault</c> except the popover's vault switches,
|
|
/// which this suite therefore does not open — that is <c>VaultVisibilityTests</c>' business, over the
|
|
/// commands themselves, and this suite would only be re-testing the same command through a slower door.
|
|
/// </para>
|
|
/// <para>
|
|
/// A <c>UserControl</c> in a bare window, for the same reason the palette's and the strip's suites are one:
|
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class NavRailTests : IAsyncLifetime
|
|
{
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
caches = ClientCacheFactory.ForMemory($"navrail-{Guid.CreateVersion7():N}");
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
shell = new MainWindowViewModel(
|
|
ClientPaths.Default,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
Substitute.For<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>())
|
|
{
|
|
State = ShellState.Unlocked,
|
|
};
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The switcher's three segments select the surface they name, and light up when it is the one showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Driven through the segments rather than through the commands directly, for the reason
|
|
/// <c>TerminalTabsTests.TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage</c> gave for its own
|
|
/// three fixed tabs: what is being checked is that three buttons in the markup are wired to three
|
|
/// different things, which three commands called by hand would not catch if two of the three were
|
|
/// bound to the same one.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheSwitcherSegmentsSelectTheirSurface_AndLightTheActiveOne()
|
|
{
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
shell.IsSshShowing.ShouldBeTrue("nothing has navigated to SFTP or S3 yet");
|
|
Segment(rail, "SSH").Classes.Contains("active").ShouldBeTrue();
|
|
|
|
Click(Segment(rail, "SFTP"), window);
|
|
|
|
shell.IsTransfersShowing.ShouldBeTrue();
|
|
Segment(rail, "SFTP").Classes.Contains("active").ShouldBeTrue();
|
|
Segment(rail, "SSH").Classes.Contains("active").ShouldBeFalse("exactly one segment lights at once");
|
|
|
|
Click(Segment(rail, "S3"), window);
|
|
|
|
shell.IsBucketsShowing.ShouldBeTrue();
|
|
shell.IsTransfersShowing.ShouldBeFalse();
|
|
Segment(rail, "S3").Classes.Contains("active").ShouldBeTrue();
|
|
|
|
Click(Segment(rail, "SSH"), window);
|
|
|
|
shell.IsSshShowing.ShouldBeTrue();
|
|
shell.IsBucketsShowing.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The mode-dependent first row follows the same three flags the switcher above lights.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The label is read straight off the row's own bound text rather than off <see cref="MainWindowViewModel"/>
|
|
/// state directly, because what a fidelity pass could break is the binding between the two, not the
|
|
/// property computing the right string on its own — <c>MainWindowViewModelTests</c> would already catch
|
|
/// that half.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheFirstRailItemsLabel_FollowsTheSwitchersMode()
|
|
{
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
FirstRow(rail).ShouldBe("Terminal");
|
|
|
|
Click(Segment(rail, "SFTP"), window);
|
|
FirstRow(rail).ShouldBe("Files");
|
|
|
|
Click(Segment(rail, "S3"), window);
|
|
FirstRow(rail).ShouldBe("Buckets");
|
|
|
|
Click(Segment(rail, "SSH"), window);
|
|
FirstRow(rail).ShouldBe("Terminal");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The user popover is a <c>Flyout</c>, and this is the same assertion
|
|
/// <c>TerminalTabsTests.TheCaretBesideVaults_OpensTheVaultMenu</c> made of the strip's own — the pointer
|
|
/// opens the popup, and nothing about its position or its content occlude anything, since it never
|
|
/// crosses into the terminal's own rectangle. See the remark in NavRail.axaml.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClickingTheUserChip_OpensThePopover()
|
|
{
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
var chip = UserChip(rail);
|
|
|
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
|
|
|
|
Click(chip, window);
|
|
|
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Each vault switch in the popover draws the vault's own display name and shown state, and is wired
|
|
/// to the command that toggles it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <see cref="MainWindowViewModel.VaultToggles"/> is fed directly rather than through a real sign-in
|
|
/// and a second vault created on a fake server. <c>VaultVisibilityTests</c>, in the Avalonia-free
|
|
/// <c>DodoSSH.Client.App.Tests</c> project, already proves <see cref="MainWindowViewModel.ToggleVaultCommand"/>
|
|
/// itself — that a hidden vault stays syncing, that the personal one refuses, and everything else the
|
|
/// command actually does once it runs. What is worth proving here, in the project that can lay markup
|
|
/// out at all, is only the wiring: that a row in this popover shows the right vault and calls that
|
|
/// command with that vault when pressed — the fact a fidelity pass to this file could actually break.
|
|
/// </para>
|
|
/// <para>
|
|
/// The command is read off the row rather than pressed, because <see cref="MainWindowViewModel.ToggleVaultCommand"/>
|
|
/// itself declines with no observable effect when <see cref="MainWindowViewModel.Vault"/> is null — which
|
|
/// it is here, for the reason above — so a press would prove nothing a reader could tell from a press
|
|
/// that reached the wrong command entirely.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task PopoverVaultRows_NameTheirVaultAndAreWiredToToggleIt()
|
|
{
|
|
var shown = new VaultToggleViewModel(Guid.CreateVersion7(), "Personal", IsPersonal: true, IsShown: true);
|
|
var hidden = new VaultToggleViewModel(Guid.CreateVersion7(), "Platform secrets", IsPersonal: false, IsShown: false);
|
|
|
|
shell.VaultToggles.Add(shown);
|
|
shell.VaultToggles.Add(hidden);
|
|
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
Click(UserChip(rail), window);
|
|
|
|
var shownRow = PopoverRow(window, shown);
|
|
var hiddenRow = PopoverRow(window, hidden);
|
|
|
|
shownRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
|
|
shownRow.CommandParameter.ShouldBeSameAs(shown);
|
|
HasVisibleCheck(shownRow).ShouldBeTrue("the personal vault is always shown");
|
|
|
|
hiddenRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
|
|
hiddenRow.CommandParameter.ShouldBeSameAs(hidden);
|
|
HasVisibleCheck(hiddenRow).ShouldBeFalse("this one was switched off");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Settings enters settings mode on its landing page, and shuts the popover behind it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// v5c gave this menu three navigation rows — Settings, Vaults and Preferences — because settings mode
|
|
/// had just been built out of what were two bare <c>ShellScreen</c>s, and "Settings" and "Preferences"
|
|
/// had been the same click until then. All three opened the same mode and differed only in which page
|
|
/// they landed on, which made two of them deep links into a place the third one opens, past a
|
|
/// <c>SettingsNav</c> that lists every page anyway. One row now; see <c>NavRail.axaml</c>'s own remark.
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
|
|
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
|
|
|
|
/// <summary>
|
|
/// The two rows that were folded into it are gone from the menu rather than merely unwired.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Worth a test of its own because the failure it guards is silent: a row left in the markup and
|
|
/// stripped of its handler is a menu entry that does nothing when pressed, which reads as a broken
|
|
/// application rather than as a tidied menu. Both destinations are still reachable — one click deeper,
|
|
/// from <c>SettingsNav</c> — which is the whole argument for removing them from here.
|
|
/// </remarks>
|
|
[Fact]
|
|
public Task ThePopoverNoLongerOffersVaultsOrPreferencesOfItsOwn() =>
|
|
OnTheRailAsync((rail, window) =>
|
|
{
|
|
Click(UserChip(rail), window);
|
|
|
|
PopoverRows(window).ShouldNotContain("Vaults");
|
|
PopoverRows(window).ShouldNotContain("Preferences");
|
|
PopoverRows(window).ShouldContain("Settings");
|
|
});
|
|
|
|
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
|
|
OnTheRailAsync((rail, window) =>
|
|
{
|
|
var chip = UserChip(rail);
|
|
Click(chip, window);
|
|
|
|
Click(PopoverRow(window, label), window);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(target);
|
|
shell.IsShowingPages.ShouldBeTrue();
|
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
|
|
});
|
|
|
|
/// <remarks>
|
|
/// Through the Account settings page rather than a direct <c>SignOutCommand</c> — see
|
|
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card is drawn inline on
|
|
/// that one page while the vault is unlocked, and arming it from anywhere else would be a card raised
|
|
/// nobody could see.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ThePopoversLogoutRow_EntersSettingsOnAccountAndArmsTheSignOutConfirmation()
|
|
{
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
var chip = UserChip(rail);
|
|
Click(chip, window);
|
|
|
|
Click(PopoverRow(window, "Logout"), window);
|
|
|
|
shell.IsSettingsMode.ShouldBeTrue();
|
|
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
|
|
shell.IsConfirmingSignOut.ShouldBeTrue();
|
|
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every row in the popover rests flat, and the pointer is what fills one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <c>Button.poprow</c> set a radius and a padding and left the Background alone, so each row wore the
|
|
/// Fluent theme's own button fill: the account menu drew as six raised pills where the design draws six
|
|
/// lines of text. Read as a colour off the templated presenter rather than off the Button, because that
|
|
/// is where the theme puts its brush and therefore the only place the absence of one can be proven.
|
|
///
|
|
/// The hover half is asserted too, and it is what stops "flat" being fixed by making the rows
|
|
/// permanently invisible to the pointer: a menu row that does not answer a pointer at all is a worse
|
|
/// answer than one that answers wrongly.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task PopoverRowsAreFlatUntilThePointerFindsThem()
|
|
{
|
|
await OnTheRailAsync((rail, window) =>
|
|
{
|
|
Click(UserChip(rail), window);
|
|
|
|
var row = PopoverRow(window, "Settings");
|
|
var presenter = row.GetVisualDescendants()
|
|
.OfType<ContentPresenter>()
|
|
.First(candidate => candidate.Name is "PART_ContentPresenter");
|
|
|
|
var resting = presenter.Background as ISolidColorBrush;
|
|
|
|
(resting is null || resting.Color.A == 0).ShouldBeTrue(
|
|
$"a popover row rests flat, and this one is filled with {resting?.Color}");
|
|
|
|
var centre = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the row is not in this window's tree");
|
|
|
|
window.MouseMove(centre);
|
|
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
|
|
|
row.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
|
|
(presenter.Background as ISolidColorBrush).ShouldNotBeNull().Color.A.ShouldNotBe(
|
|
(byte)0,
|
|
"a row that does not change under the pointer is one nobody can tell is clickable");
|
|
});
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var rail = new NavRail { DataContext = shell };
|
|
var window = new Window { Content = rail };
|
|
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
|
|
|
try
|
|
{
|
|
body(rail, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
private static Button Segment(Visual rail, string label) =>
|
|
rail.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.Classes.Contains("navseg")
|
|
&& button.GetVisualDescendants()
|
|
.OfType<TextBlock>()
|
|
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
|
|
|
private static Button UserChip(Visual rail) =>
|
|
rail.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("navuser"));
|
|
|
|
/// <summary>
|
|
/// A row inside the open popover, found by its own data context. Searched from the window rather than
|
|
/// from the rail: a <c>Flyout</c>'s content is a popup, hosted in the window's own overlay layer rather
|
|
/// than nested inside the control that owns it, so it is outside <c>rail.GetVisualDescendants()</c>.
|
|
/// </summary>
|
|
private static Button PopoverRow(Visual window, VaultToggleViewModel toggle) =>
|
|
window.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.Classes.Contains("poprow") && ReferenceEquals(button.DataContext, toggle));
|
|
|
|
/// <summary>A navigation row inside the open popover, found by the word on it.</summary>
|
|
/// <inheritdoc cref="PopoverRow(Visual, VaultToggleViewModel)" path="/summary" />
|
|
private static Button PopoverRow(Visual window, string label) =>
|
|
window.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.Classes.Contains("poprow")
|
|
&& button.GetVisualDescendants()
|
|
.OfType<TextBlock>()
|
|
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
|
|
|
|
/// <summary>
|
|
/// Every word the open popover offers as a place to go.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The vault switches are excluded by their data context rather than by their label, because a vault is
|
|
/// free to be called anything — including "Vaults" — and a test asserting that a menu entry is absent
|
|
/// must not be satisfiable or defeatable by what somebody named a vault.
|
|
/// </remarks>
|
|
private static IReadOnlyList<string> PopoverRows(Visual window) =>
|
|
[.. window.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.Where(button => button.Classes.Contains("poprow")
|
|
&& button.DataContext is not VaultToggleViewModel)
|
|
.SelectMany(button => button.GetVisualDescendants().OfType<TextBlock>())
|
|
.Select(text => text.Text)
|
|
.Where(text => !string.IsNullOrEmpty(text))
|
|
.Select(text => text!)];
|
|
|
|
/// <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);
|
|
}
|
|
}
|