Public Access
Give the window its v5b chrome and each session surface its own shell
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user