Public Access
206 lines
8.3 KiB
C#
206 lines
8.3 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
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>
|
|
/// The titlebar's own search pill still opens the quick-connect palette after v5b's redraw.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A narrow suite on purpose: <see cref="LayoutHarnessTests"/> already holds the bar to its declared
|
|
/// height and proves nothing inside it is unreachable, and the search pill's own accent-on-hover border is
|
|
/// a style rule with nothing to assert headlessly. What is worth a gesture is the one thing a fidelity pass
|
|
/// could quietly break without any layout test noticing — the pill no longer opening what Ctrl+K opens.
|
|
/// </remarks>
|
|
public sealed class TitleBarTests : 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($"titlebar-{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>())
|
|
{
|
|
// The search pill is disabled while locked — see TitleBar.axaml's IsEnabled binding — and this
|
|
// is the only state that gesture is reachable in.
|
|
State = ShellState.Unlocked,
|
|
};
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A double-click on the bar maximises, and a second one restores. It was a <c>DoubleTapped</c> handler
|
|
/// beside a press that started a drag, and the drag handed the press to the operating system's move loop,
|
|
/// which ate the release a double tap is assembled from — so on Windows the click often did nothing. The
|
|
/// press decides now; see <c>CaptionPresses</c>. A single press must still be a drag and nothing else.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task DoubleClickingTheBar_MaximisesAndRestores_AndASinglePressDoesNot()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var bar = new TitleBar { DataContext = shell };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
|
|
|
try
|
|
{
|
|
// The top edge, past everything the bar draws: the drag surface and nothing else.
|
|
var empty = new Point(bar.Bounds.Width * 0.6, 2);
|
|
|
|
window.MouseDown(empty, MouseButton.Left);
|
|
window.MouseUp(empty, MouseButton.Left);
|
|
|
|
window.WindowState.ShouldBe(WindowState.Normal, "one press is a drag");
|
|
|
|
window.MouseDown(empty, MouseButton.Left);
|
|
window.MouseUp(empty, MouseButton.Left);
|
|
|
|
window.WindowState.ShouldBe(WindowState.Maximized);
|
|
|
|
window.MouseDown(empty, MouseButton.Left);
|
|
window.MouseUp(empty, MouseButton.Left);
|
|
window.MouseDown(empty, MouseButton.Left);
|
|
window.MouseUp(empty, MouseButton.Left);
|
|
|
|
window.WindowState.ShouldBe(WindowState.Normal);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var bar = new TitleBar { DataContext = shell };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
|
|
|
try
|
|
{
|
|
shell.IsSearching.ShouldBeFalse("nothing has been pressed yet");
|
|
|
|
var pill = bar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => button.Classes.Contains("search"));
|
|
|
|
var centre = pill.TranslatePoint(
|
|
new Point(pill.Bounds.Width / 2, pill.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the pill is not in this window's tree");
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
shell.IsSearching.ShouldBeTrue(
|
|
"the pill is the click-through path Ctrl+K also opens — see ToggleSearchCommand");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The chip that says which chord opens the pill beside it. The design's own is 34 pixels wide because
|
|
/// the design's own label is ⌘K — two glyphs — and this build substitutes "CTRL K", which at 10.5 mono is
|
|
/// wider than that. It shipped clipped: the chip drew "CTRL" and half of the K, which reads as a rendering
|
|
/// glitch rather than as a keyboard shortcut.
|
|
///
|
|
/// Asserted as "the chip is at least as wide as its own text", not against a number. A pixel count would
|
|
/// have to be re-derived by hand every time the font, the size or the wording moved, and the thing that
|
|
/// actually matters is the relationship between the two.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheKeyboardChipIsWideEnoughForTheChordItNames()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
var bar = new TitleBar { DataContext = shell };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
|
|
|
try
|
|
{
|
|
var label = bar.GetVisualDescendants()
|
|
.OfType<TextBlock>()
|
|
.Single(text => string.Equals(text.Text, "CTRL K", StringComparison.Ordinal));
|
|
|
|
var chip = label.GetVisualAncestors().OfType<Border>().First();
|
|
|
|
// Measured on a copy under an unbounded constraint, not read off the label in the tree.
|
|
// A TextBlock's own DesiredSize is already clipped to what it was given, so the laid-out
|
|
// one reports 34 inside a 34-pixel chip whether or not the text fits — which is exactly
|
|
// the state this test exists to fail on.
|
|
var natural = new TextBlock
|
|
{
|
|
Text = label.Text,
|
|
FontFamily = label.FontFamily,
|
|
FontSize = label.FontSize,
|
|
FontWeight = label.FontWeight,
|
|
};
|
|
|
|
natural.Measure(Size.Infinity);
|
|
|
|
natural.DesiredSize.Width.ShouldBeGreaterThan(0, "the chord is a real run of text");
|
|
chip.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
|
|
natural.DesiredSize.Width,
|
|
"a chip narrower than its own label draws part of the chord and cuts the rest");
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
}
|