using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests;
///
/// Lays out real XAML at a real size and reports anything a user could not click.
///
///
///
/// 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 .axaml — and this window has shipped it once, when the
/// setup screens rendered sliced with their buttons unreachable at the default width.
///
///
/// A control inside a 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.
///
///
internal static class LayoutHarness
{
/// The window's own declared minimum, which is the size that has to work.
///
/// Taken from MainWindow.axaml's MinWidth/MinHeight 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.
///
internal const double MinimumWidth = 1016;
///
internal const double MinimumHeight = 574;
/// The hosts drawer's fixed width, from HostDrawer.axaml.
///
/// This was HostSidebarWidth 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
/// Auto — so the number lives on the control now, and this constant follows it.
///
internal const double HostDrawerWidth = 304;
/// The nav rail's fixed width, from NavRail.axaml.
internal const double NavRailWidth = 190;
///
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
///
///
/// All three are fixed heights declared in their own markup — 44, 42 and 24 — rather than shapes that
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
/// controls to those numbers, so the budget below cannot drift away from what the window actually
/// leaves.
///
internal const double TitleBarHeight = 44;
///
internal const double StatusBarHeight = 24;
///
///
///
///
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
/// and went would move every screen up and down by 42 pixels each time the last tab closed — so this is
/// a fixed cost rather than a conditional one, and the budget can be a constant.
///
internal const double TerminalTabsHeight = 42;
/// The update banner's fixed height, from UpdateBanner.axaml.
///
/// Deliberately not part of '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.
///
internal const double UpdateBannerHeight = 48;
///
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
///
///
/// From Border.card in App.axaml — MaxWidth 520 and Padding 24 — because the
/// cards themselves live inside MainWindow.axaml, 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.
///
internal const double CardContentWidth = 520 - (2 * 24);
///
///
/// Measured against and not against , which is a
/// distinction the tab strip introduced and which is worth stating: a setup card is shown while the
/// vault is not 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.
///
internal static double CardContentHeight => ContentHeight - (2 * 24);
/// Everything between the titlebar and the status bar, at the window's minimum.
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// The height a screen actually gets at the window's minimum.
///
/// Less than by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
///
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
/// The width a full-width screen gets, once the nav rail has taken its column.
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
private static readonly HeadlessUnitTestSession Session =
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
///
/// Runs one body on Avalonia's dispatcher thread.
///
///
/// 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.
///
internal static Task OnTheUiThreadAsync(Action body, CancellationToken cancellationToken) =>
Session.Dispatch(body, cancellationToken);
/// Shows a window at a given size and lets layout finish.
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();
}
/// Wraps a control in a host window sized to the application's minimum.
internal static Window HostAtMinimumSize(Control content, double width, double height)
{
var window = new Window { Content = content };
Settle(window, width, height);
return window;
}
///
/// Every interactive control that is laid out where it cannot be used, described for a failure message.
///
///
/// 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.
///
internal static IReadOnlyList Unreachable(Window window)
{
ArgumentNullException.ThrowIfNull(window);
var client = new Rect(window.ClientSize);
var found = new List();
foreach (var control in window.GetVisualDescendants().OfType())
{
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");
}
///
/// The controls a user has to be able to reach. A clipped is a cosmetic problem
/// and a clipped 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.
///
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().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)}");
}
///
/// A button's caption, because "Save" identifies the control to a reader far better than its position
/// in a visual tree does.
///
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.#}");
}