Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/SessionTabRowTests.cs
T

392 lines
15 KiB
C#

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