Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
T
jaap-jan 208443b932 Move SFTP and S3 into the tab strip, and the host list into a card grid
Four asks in one pass over the desktop head, and two of them are furniture
moving rather than anything new.

THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at
its head and cannot be closed, and open terminals follow them. SFTP and S3 left
the nav rail to get there, which is the one semantic change: they are the two
destinations you stay in while something runs, and a rail entry is for
somewhere you go and come back from. So the rail belongs to the Vaults tab and
collapses with it, which also means SFTP, S3 and a terminal each get the full
1016 rather than the 826 a page gets.

The tab is expressed as "a page, and not one of the two the strip took" rather
than as a fourth ShellSurface. Both are still ShellScreen members and have to
be — that is what they are on the phone, where they are two rows in a hub — so
a surface each would have been a second way to say a thing Screen already says.
IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged.
What is new is one field: the page Vaults comes back to, because it is the one
tab with sub-navigation and therefore the one that can return to the wrong
place. That is not the hidden field ShellSurface argues against — that one
would be a second copy of "which page"; this is a tab remembering its own.

THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was
choosing among forty machines and editing one of them at two-thirds width, and
it was narrow so the editor beneath it could be a column at all. Cards took the
first job at full width; a 304-pixel drawer took the second and collapses when
nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the
way back. The group editor moved into the drawer as well, which finally makes
IsEditingGroup mean the same thing on both heads — it was the phone's alone,
because the desktop's editor was a bar that was always on screen.

AreHostsExpanded and ToggleHosts went with the control that used them. They
folded the whole list away under one heading, an affordance that existed
because the column was narrow. Folding a single group is a different thing and
is still here.

THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was
never stated anywhere — a bare TextBlock took TextElement's default of 12 — so
raising the scale meant naming it, on Window and on UserControl. The second
selector is not redundancy: the layout harness hosts a UserControl in a window
it builds itself, and without it the suite would measure every screen a point
smaller than it ships, silently. A selector on TextBlock would have been the
obvious way and is wrong, because a style setter beats an inherited value and
would collapse every deliberate step back to one number.

#E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast
twice — once for being darker than white and once for sharing a hue with what
it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1.
Every step below moved with the top, so the intervals the design chose are
kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone
has both changes too.

TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen
rather than by measuring it, and both now covered.

The tile was 232 and was first written as 248, from arithmetic that left out
the scrolling stack's own margins. Every layout test passed — the harness asks
whether a control is inside the window, never how many fit on a line — so the
grid quietly became one column wide at exactly the minimum this application
guarantees, which is the shape cards exist to avoid.
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns
instead, and fails at 248.

And a card's text ran past its own border, because a horizontal StackPanel
measures children with infinite width: a TextBlock inside one never learns it
is short of room, so TextTrimming never fires. Both card rows are grids with a
star column that gives way and an Auto column that does not — a hostname with
its tail cut is still the machine you were looking for, where a badge or the
word naming an auth method is not.

The keychain header changed shape for the same class of reason. It was
Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing
absorbing a change of width and five buttons fell off the right edge the moment
the type grew. That is how GENERATE lost the word KEY once already. The summary
sits in the star column and trims now, so the buttons always get their width.

HostSidebarTests became HostGridTests and moved to the grid with the gestures
it drives. docs/design-import-gaps.md gains a v3 section naming the five
toolbar controls in the design with nothing behind them — a view-mode switch, a
tag filter, a calendar, a share control and Serial — and manual-checks.md and
the README follow the controls that moved.
2026-08-03 15:08:48 +02:00

240 lines
11 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.
/// </remarks>
internal const double MinimumWidth = 1016;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
/// <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.
/// </remarks>
internal const double HostDrawerWidth = 304;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
/// <summary>
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal const double TitleBarHeight = 44;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal const double TerminalTabsHeight = 42;
/// <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 screen actually gets at the window's minimum.</summary>
/// <remarks>
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
/// </remarks>
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
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.#}");
}