Public Access
392 lines
15 KiB
C#
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");
|
|
}
|