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. /// /// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added — /// see , and the matching remark in /// MainWindow.axaml. and are both unchanged /// by the move, because the minimum grew by exactly what the two grew by. /// internal const double MinimumWidth = 1081; /// internal const double MinimumHeight = 583; /// 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. It was 304 through /// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and App.axaml's /// Border.tile narrowed to keep two columns fitting the grid beside it at the window's minimum. /// internal const double HostDrawerWidth = 320; /// The nav rail's fixed width, from NavRail.axaml. /// v5b: 190 became 255, the design's own number rather than this bar's old approximation. internal const double NavRailWidth = 255; /// Settings mode's own rail, from SettingsNav.axaml — wider than . internal const double SettingsNavWidth = 340; /// /// The width settings mode's own content column asks for, from the design's width:1100px. /// /// /// A MaxWidth on the page, not a Width — see the same trade TitleBar.axaml's own /// search box makes with its own MaxWidth="514", and for the identical reason: /// 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. /// internal const double SettingsDesignContentWidth = 1100; /// The width a settings page's content column actually gets at the window's minimum. internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth; /// What settings mode leaves a page between its own titlebar and the window's bottom edge. /// /// Settings mode has no status bar and no update banner of its own — see MainWindow.axaml's own /// remark on why both are hidden while IsSettingsMode is true — so this is /// less only , not 's /// own subtraction of too. /// internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight; /// /// What the titlebar and the status bar take off the window before any screen gets a pixel. /// /// /// 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 for /// the matching rise that keeps every screen below it the same size it always measured. /// /// A third constant, TerminalTabsHeight, 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 MainWindow.axaml's own remark /// on where a session's tabs live now — and with it the constant: 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 /// , which only those two screens' own budgets pay. /// internal const double TitleBarHeight = 53; /// internal const double StatusBarHeight = 24; /// The v5b session shell's own tab row, from App.axaml's Button.sesstab rule. /// /// Not part of '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 /// — rather than every screen paying it as chrome. Stated here so a /// test can hold SessionTabRow to it the same way TheChromeIsTheHeightTheBudgetAssumes /// holds the titlebar and the status bar to theirs. /// internal const double SessionTabRowHeight = 38; /// The v5b session shell's own padded column, from the design's padding: 26px. internal const double SessionShellPadding = 26; /// The v5b session shell's own right-hand sidebar, from SessionSidebar.axaml. internal const double SessionSidebarWidth = 300; /// The v5b session shell's own host header, from SessionHeader.axaml. internal const double SessionHeaderHeight = 60; /// The v5b session shell's own status bar, from SessionStatusBar.axaml. internal const double SessionStatusBarHeight = 37; /// /// The bordered container both session-shell screens sit inside, from MainWindow.axaml's /// BorderThickness="1" around the header/pane/status-bar column and the sidebar beside it. /// internal const double SessionShellBorderThickness = 1; /// /// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — TransfersScreen /// 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 /// MeasureConnectingAsync and MeasureHostKeyAsync measuring at the roomier /// / instead, with a remark on each admitting the gap; /// this is what closes it. /// /// /// The arithmetic, top to bottom: less on /// both the top and the bottom of the outer padded column, less for the /// tab row that sits above the bordered container, less on both /// the top and the bottom of that border, less and /// for the two fixed strips the pane sits between. /// internal static double SessionScreenHeight => ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness) - SessionHeaderHeight - SessionStatusBarHeight; /// /// The width a session-shell screen gets, with or without SessionSidebar's own QUICK ACCESS /// column showing beside it. /// /// /// less on both the left and the right of the /// outer padded column, less on both the left and the right of /// the bordered container, less when the sidebar is showing beside the /// pane rather than collapsed — see MainWindowViewModel.ShowsQuickAccessSidebar, which for the /// SFTP surface is exactly Transfers.IsConnected: 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. /// internal static double SessionScreenWidth(bool sidebarVisible) => ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness) - (sidebarVisible ? SessionSidebarWidth : 0); /// 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.axamlMaxWidth 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 full-bleed page screen actually gets at the window's minimum. /// /// /// Equal to since v5b's session-shell wave retired the window-wide tab strip /// that used to be subtracted here — see '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. /// internal static double ScreenHeight => ContentHeight; /// The width a full-width screen gets, once the nav rail has taken its column. internal static double ScreenWidth => MinimumWidth - NavRailWidth; /// /// v5c-3: what the S3 usage of TransfersScreen gets, now that MainWindow.axaml 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. /// internal static double BucketsScreenWidth => ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness); /// internal static double BucketsScreenHeight => ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness); 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.#}"); }