Public Access
365 lines
19 KiB
C#
365 lines
19 KiB
C#
using System.Globalization;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Threading;
|
|
using Avalonia.VisualTree;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// Lays out real XAML at a real size and reports anything a user could not click.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The defect this exists for is a control arranged past the edge of its container. It is invisible to every
|
|
/// other suite here — no other test loads a <c>.axaml</c> — and this window has shipped it once, when the
|
|
/// setup screens rendered sliced with their buttons unreachable at the default width.
|
|
/// </para>
|
|
/// <para>
|
|
/// A control inside a <see cref="ScrollViewer"/> is exempt, and that exemption is load-bearing rather than a
|
|
/// convenience: a list longer than its viewport is the normal case, and treating a row scrolled out of sight
|
|
/// as a defect would make this harness cry wolf on every populated list. What is left after the exemption is
|
|
/// the class of thing that has no way to come back into view.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class LayoutHarness
|
|
{
|
|
/// <summary>The window's own declared minimum, which is the size that has to work.</summary>
|
|
/// <remarks>
|
|
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
|
|
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
|
|
/// than the one a user is allowed to drag to.
|
|
///
|
|
/// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added —
|
|
/// see <see cref="TitleBarHeight"/>, <see cref="NavRailWidth"/> and the matching remark in
|
|
/// <c>MainWindow.axaml</c>. <see cref="ScreenWidth"/> and <see cref="ScreenHeight"/> are both unchanged
|
|
/// by the move, because the minimum grew by exactly what the two grew by.
|
|
/// </remarks>
|
|
internal const double MinimumWidth = 1081;
|
|
|
|
/// <inheritdoc cref="MinimumWidth" />
|
|
internal const double MinimumHeight = 583;
|
|
|
|
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
|
|
/// <remarks>
|
|
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
|
|
/// drawer states its own width instead — it is the only thing in its column and the column is
|
|
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it. It was 304 through
|
|
/// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and <c>App.axaml</c>'s
|
|
/// <c>Border.tile</c> narrowed to keep two columns fitting the grid beside it at the window's minimum.
|
|
/// </remarks>
|
|
internal const double HostDrawerWidth = 320;
|
|
|
|
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
|
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
|
internal const double NavRailWidth = 255;
|
|
|
|
/// <summary>Settings mode's own rail, from <c>SettingsNav.axaml</c> — wider than <see cref="NavRailWidth"/>.</summary>
|
|
internal const double SettingsNavWidth = 340;
|
|
|
|
/// <summary>
|
|
/// The width settings mode's own content column asks for, from the design's <c>width:1100px</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A <c>MaxWidth</c> on the page, not a <c>Width</c> — see the same trade <c>TitleBar.axaml</c>'s own
|
|
/// search box makes with its own <c>MaxWidth="514"</c>, and for the identical reason:
|
|
/// <see cref="SettingsContentWidth"/> below is smaller than this at the window's minimum, and a page
|
|
/// that insisted on the full 1100 would arrange its own rows past the edge of the rectangle settings
|
|
/// mode actually gives them.
|
|
/// </remarks>
|
|
internal const double SettingsDesignContentWidth = 1100;
|
|
|
|
/// <summary>The width a settings page's content column actually gets at the window's minimum.</summary>
|
|
internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth;
|
|
|
|
/// <summary>What settings mode leaves a page between its own titlebar and the window's bottom edge.</summary>
|
|
/// <remarks>
|
|
/// Settings mode has no status bar and no update banner of its own — see <c>MainWindow.axaml</c>'s own
|
|
/// remark on why both are hidden while <c>IsSettingsMode</c> is true — so this is
|
|
/// <see cref="MinimumHeight"/> less only <see cref="TitleBarHeight"/>, not <see cref="ContentHeight"/>'s
|
|
/// own subtraction of <see cref="StatusBarHeight"/> too.
|
|
/// </remarks>
|
|
internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight;
|
|
|
|
/// <summary>
|
|
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Both are fixed heights declared in their own markup — 53 and 24 — rather than shapes that grow with
|
|
/// their contents, which is what makes stating them here honest. A test holds each control to its own
|
|
/// number, so the budget below cannot drift away from what the window actually leaves.
|
|
///
|
|
/// v5b: the titlebar's own 44 became 53, the design's own height; see <see cref="MinimumHeight"/> for
|
|
/// the matching rise that keeps every screen below it the same size it always measured.
|
|
///
|
|
/// A third constant, <c>TerminalTabsHeight</c>, stood beside these two through v5b's chrome wave: the
|
|
/// window-wide tab strip that used to sit above every screen, 42 pixels, whether or not there were any
|
|
/// tabs to draw. v5b's session-shell wave retires that strip — see <c>MainWindow.axaml</c>'s own remark
|
|
/// on where a session's tabs live now — and with it the constant: <see cref="ScreenHeight"/> no longer
|
|
/// subtracts anything for a row that no longer exists as chrome above every screen. The tab row itself
|
|
/// is now inside the two screens that carry one, at its own 38-pixel height; see
|
|
/// <see cref="SessionTabRowHeight"/>, which only those two screens' own budgets pay.
|
|
/// </remarks>
|
|
internal const double TitleBarHeight = 53;
|
|
|
|
/// <inheritdoc cref="TitleBarHeight" />
|
|
internal const double StatusBarHeight = 24;
|
|
|
|
/// <summary>The v5b session shell's own tab row, from <c>App.axaml</c>'s <c>Button.sesstab</c> rule.</summary>
|
|
/// <remarks>
|
|
/// Not part of <see cref="ScreenHeight"/>'s budget, unlike the retired window-wide strip this replaced:
|
|
/// only the terminal and SFTP surfaces pay it, out of their own 26-pixel padded column — see
|
|
/// <see cref="SessionShellPadding"/> — rather than every screen paying it as chrome. Stated here so a
|
|
/// test can hold <c>SessionTabRow</c> to it the same way <c>TheChromeIsTheHeightTheBudgetAssumes</c>
|
|
/// holds the titlebar and the status bar to theirs.
|
|
/// </remarks>
|
|
internal const double SessionTabRowHeight = 38;
|
|
|
|
/// <summary>The v5b session shell's own padded column, from the design's <c>padding: 26px</c>.</summary>
|
|
internal const double SessionShellPadding = 26;
|
|
|
|
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
|
internal const double SessionSidebarWidth = 300;
|
|
|
|
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
|
|
internal const double SessionHeaderHeight = 60;
|
|
|
|
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
|
internal const double SessionStatusBarHeight = 37;
|
|
|
|
/// <summary>
|
|
/// The bordered container both session-shell screens sit inside, from <c>MainWindow.axaml</c>'s
|
|
/// <c>BorderThickness="1"</c> around the header/pane/status-bar column and the sidebar beside it.
|
|
/// </summary>
|
|
internal const double SessionShellBorderThickness = 1;
|
|
|
|
/// <summary>
|
|
/// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — <c>TransfersScreen</c>
|
|
/// today, and whatever sits in the terminal's own pane — actually gets once the session shell built in
|
|
/// wave B has taken its padding, its tab row, its header and its status bar. Wave B left
|
|
/// <c>MeasureConnectingAsync</c> and <c>MeasureHostKeyAsync</c> measuring at the roomier
|
|
/// <see cref="ScreenWidth"/>/<see cref="ScreenHeight"/> instead, with a remark on each admitting the gap;
|
|
/// this is what closes it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
|
|
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
|
|
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
|
|
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
|
|
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
|
|
/// </remarks>
|
|
internal static double SessionScreenHeight =>
|
|
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
|
- SessionHeaderHeight - SessionStatusBarHeight;
|
|
|
|
/// <summary>
|
|
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
|
/// column showing beside it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="ScreenWidth"/> less <see cref="SessionShellPadding"/> on both the left and the right of the
|
|
/// outer padded column, less <see cref="SessionShellBorderThickness"/> on both the left and the right of
|
|
/// the bordered container, less <see cref="SessionSidebarWidth"/> when the sidebar is showing beside the
|
|
/// pane rather than collapsed — see <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>, which for the
|
|
/// SFTP surface is exactly <c>Transfers.IsConnected</c>: the caller passes that fact in rather than this
|
|
/// harness guessing it, because it is a fact about a view model this file knows nothing about.
|
|
/// </remarks>
|
|
internal static double SessionScreenWidth(bool sidebarVisible) =>
|
|
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness)
|
|
- (sidebarVisible ? SessionSidebarWidth : 0);
|
|
|
|
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
|
|
/// <remarks>
|
|
/// Deliberately <em>not</em> part of <see cref="ScreenHeight"/>'s budget, unlike the three constants
|
|
/// above it. The titlebar, the tab strip and the status bar are unconditional — every screen pays them
|
|
/// on every launch, which is what makes subtracting them honest. This one is up only while an update is
|
|
/// waiting to be installed, so folding it into the budget would have every screen measured against a
|
|
/// height it usually has more than. What it does mean is that a screen shown with the banner up gets 48
|
|
/// fewer pixels than the suite otherwise checks, which is the trade this row makes and the reason it is
|
|
/// one line high.
|
|
/// </remarks>
|
|
internal const double UpdateBannerHeight = 48;
|
|
|
|
/// <summary>
|
|
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// From <c>Border.card</c> in <c>App.axaml</c> — <c>MaxWidth</c> 520 and <c>Padding</c> 24 — because the
|
|
/// cards themselves live inside <c>MainWindow.axaml</c>, which cannot be laid out here at all. Measuring
|
|
/// a card's contents at the size the card gives them is the closest this harness can get to the unlock
|
|
/// screen, and it is the half that has something to blow: the frame is fixed and the contents are not.
|
|
/// </remarks>
|
|
internal const double CardContentWidth = 520 - (2 * 24);
|
|
|
|
/// <inheritdoc cref="CardContentWidth" />
|
|
/// <remarks>
|
|
/// Measured against <see cref="ContentHeight"/> and not against <see cref="ScreenHeight"/>, which is a
|
|
/// distinction the tab strip introduced and which is worth stating: a setup card is shown while the
|
|
/// vault is <em>not</em> open, and the strip lives inside the unlocked half of the window. So the card
|
|
/// gets the whole area between the titlebar and the status bar, and taking the strip off its budget
|
|
/// would have this harness fail a card that fits.
|
|
/// </remarks>
|
|
internal static double CardContentHeight => ContentHeight - (2 * 24);
|
|
|
|
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
|
|
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
|
|
|
/// <summary>
|
|
/// The height a full-bleed page screen actually gets at the window's minimum.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Equal to <see cref="ContentHeight"/> since v5b's session-shell wave retired the window-wide tab strip
|
|
/// that used to be subtracted here — see <see cref="TitleBarHeight"/>'s own remark. The terminal and
|
|
/// SFTP surfaces pay for their own tab row, header and status bar out of their own budget now, which
|
|
/// this constant does not describe; a test measuring either of those two screens has to account for the
|
|
/// session shell's own geometry rather than reading it off this property.
|
|
/// </remarks>
|
|
internal static double ScreenHeight => ContentHeight;
|
|
|
|
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
|
|
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
|
|
|
|
/// <summary>
|
|
/// v5c-3: what the S3 usage of <c>TransfersScreen</c> gets, now that <c>MainWindow.axaml</c> gives it the
|
|
/// session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — no tab row, header,
|
|
/// status bar or sidebar to take further space off it.
|
|
/// </summary>
|
|
internal static double BucketsScreenWidth =>
|
|
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
|
|
|
|
/// <inheritdoc cref="BucketsScreenWidth" />
|
|
internal static double BucketsScreenHeight =>
|
|
ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
|
|
|
|
private static readonly HeadlessUnitTestSession Session =
|
|
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
|
|
|
|
/// <summary>
|
|
/// Runs one body on Avalonia's dispatcher thread.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Everything that touches a control has to happen here. The session owns the thread and the
|
|
/// application, so this is also what serialises the suite — Avalonia's platform is process-global and
|
|
/// two tests laying out windows at once would share one dispatcher.
|
|
/// </remarks>
|
|
internal static Task OnTheUiThreadAsync(Action body, CancellationToken cancellationToken) =>
|
|
Session.Dispatch(body, cancellationToken);
|
|
|
|
/// <summary>Shows a window at a given size and lets layout finish.</summary>
|
|
internal static void Settle(Window window, double width, double height)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(window);
|
|
|
|
window.Width = width;
|
|
window.Height = height;
|
|
|
|
// None, because a headless window still reserves space for decorations it does not draw, and the
|
|
// budget being measured is the client area the application actually gets.
|
|
window.WindowDecorations = WindowDecorations.None;
|
|
|
|
window.Show();
|
|
|
|
// Show() queues layout rather than performing it. Without this the tree is measured but not
|
|
// arranged, and every Bounds read below would be a zero rectangle — which would make this harness
|
|
// report the whole window as unreachable, or worse, report nothing at all.
|
|
Dispatcher.UIThread.RunJobs();
|
|
window.UpdateLayout();
|
|
}
|
|
|
|
/// <summary>Wraps a control in a host window sized to the application's minimum.</summary>
|
|
internal static Window HostAtMinimumSize(Control content, double width, double height)
|
|
{
|
|
var window = new Window { Content = content };
|
|
Settle(window, width, height);
|
|
return window;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every interactive control that is laid out where it cannot be used, described for a failure message.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Returns descriptions rather than controls because the value of this harness is entirely in what it
|
|
/// says when it fails: "something is clipped" sends the reader back to a 500-line XAML file, while
|
|
/// "Button 'Save' at 8,486 486x32 falls outside 820x520" names the control and the edge it crossed.
|
|
/// </remarks>
|
|
internal static IReadOnlyList<string> Unreachable(Window window)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(window);
|
|
|
|
var client = new Rect(window.ClientSize);
|
|
var found = new List<string>();
|
|
|
|
foreach (var control in window.GetVisualDescendants().OfType<Control>())
|
|
{
|
|
if (Fault(control, client, window) is { } fault)
|
|
{
|
|
found.Add(fault);
|
|
}
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
private static string? Fault(Control control, Rect client, Visual window)
|
|
{
|
|
if (!IsInteractive(control) || !control.IsEffectivelyVisible || IsScrollable(control))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (control.TranslatePoint(default, window) is not { } origin)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var box = new Rect(origin, control.Bounds.Size);
|
|
|
|
// Zero size is a defect for a control the theme gives a height to and a normal state for one sized by
|
|
// its content: an empty list is zero pixels tall and correct, a squashed button is neither. Learned
|
|
// from this firing on KeyList in a vault with no keys in it.
|
|
if (control is not ListBox && (box.Width <= 0 || box.Height <= 0))
|
|
{
|
|
return Describe(control, box, client, "was arranged with no size");
|
|
}
|
|
|
|
return client.Contains(box) ? null : Describe(control, box, client, "falls outside the window");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The controls a user has to be able to reach. A clipped <see cref="TextBlock"/> is a cosmetic problem
|
|
/// and a clipped <see cref="Button"/> is a dead end, so only the second kind is worth failing a build
|
|
/// over — and keeping the list short is what stops this harness from becoming a pixel-diff nobody
|
|
/// trusts.
|
|
/// </remarks>
|
|
private static bool IsInteractive(Control control) =>
|
|
control is Button or TextBox or CheckBox or ComboBox or NumericUpDown or ListBox;
|
|
|
|
private static bool IsScrollable(Control control) =>
|
|
control.GetVisualAncestors().OfType<ScrollViewer>().Any();
|
|
|
|
private static string Describe(Control control, Rect box, Rect client, string fault)
|
|
{
|
|
var name = control.Name is { Length: > 0 } named ? $" '{named}'" : Label(control);
|
|
|
|
return string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{control.GetType().Name}{name} {fault}: {Format(box)} is not inside {Format(client)}");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// A button's caption, because "Save" identifies the control to a reader far better than its position
|
|
/// in a visual tree does.
|
|
/// </remarks>
|
|
private static string Label(Control control) => control switch
|
|
{
|
|
Button { Content: string caption } => $" '{caption}'",
|
|
TextBox { PlaceholderText: { Length: > 0 } placeholder } => $" (placeholder '{placeholder}')",
|
|
_ => string.Empty,
|
|
};
|
|
|
|
private static string Format(Rect rect) => string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{rect.X:0.#},{rect.Y:0.#} {rect.Width:0.#}x{rect.Height:0.#}");
|
|
}
|