using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Layout;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Layout.Tests;
///
/// The button shapes that centre their caption do, and the two that deliberately do not still fill.
///
///
///
/// ◆ THE DEFECT THIS EXISTS FOR was read as a height problem and was not one. App.axaml's
/// Button.ghost, Button.accent, Button.danger rule set VerticalAlignment — where the button
/// sits in its parent — and never VerticalContentAlignment, where the caption sits in the button.
/// Avalonia's default for the second is Stretch, so on any of these given a fixed Height the
/// content presenter stretched the caption's to the whole content box, and a
/// draws its line at the TOP of its bounds. The hosts toolbar's three 40-pixel
/// buttons measured 9 pixels above the ink and 20 below it. Every box was the height it declared, which is
/// exactly why it read as one being wrong: nothing was mis-sized, the labels sat in the top third.
///
///
/// A bare rather than a screen, because Application.Styles is global — the same
/// property leans on — so this measures the style rule itself rather than
/// one of the hundred-odd places it lands. A screen-level test would pin one toolbar and leave the dialogs,
/// the drawer's Save/Cancel pair and the import screen's buttons uncovered by the thing that fixed them all.
///
///
/// ◆ THE LINE BOX IS MEASURED, NOT THE TextBlock's ARRANGED BOUNDS, and the difference is the whole test.
/// A stretched caption's fill the content box, so they are symmetrical about
/// the button's middle under the defect just as they are under the fix — the first draft of this suite
/// asserted on them, passed on both, and was caught only by
/// . What actually moves is where the line sits
/// INSIDE those bounds: draws at the top of whatever it is given, so the ink is
/// centred only when the box it is drawn in is its own line box.
///
///
/// The gaps are compared to each other rather than to a number. What the caption's own line box measures is
/// a property of JetBrains Mono at whichever size the caller set — 11.5 by default and 13.5 on the primary
/// action — so an absolute expectation would be a font metric written down in a test file, and it would move
/// the day the face does. "Centred" survives both.
///
///
/// The five shapes beyond the original three were swept in afterwards, and none of them was misbehaving
/// when it was: every one is content-sized everywhere it is used today, so Stretch and Center
/// agreed and the change moved nothing — 113 buttons across 29 screens measured byte-identical before and
/// after. What the sweep buys is that the day any of them is given a height, it is already right. That is
/// also why matters more than it looks: the same
/// reasoning applied to flat or cat would break a pill and a strip that are currently
/// correct.
///
///
public sealed class ButtonCaptionTests
{
/// Taller than any caption these carry, which is the condition that exposes the defect.
///
/// The hosts and keychain toolbars' own number. A button left to size itself cannot show this at all:
/// its content box is its caption's line box exactly, so Stretch and Center agree and a
/// test written against one would pass under either.
///
private const double FixedHeight = 40;
/// One pixel, for a content box whose odd leftover cannot be halved evenly.
private const double Tolerance = 1;
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Theory]
[InlineData("ghost")]
[InlineData("accent")]
[InlineData("danger")]
[InlineData("navuser")]
[InlineData("poprow")]
[InlineData("panechip")]
[InlineData("chiptoggle")]
[InlineData("choice")]
public async Task ACaptionIsCentredInAButtonTallerThanItself(string shape)
{
await MeasureAsync(
shape,
alignment: null,
(above, below) =>
Math.Abs(above - below).ShouldBeLessThanOrEqualTo(
Tolerance,
string.Create(
CultureInfo.InvariantCulture,
$"Button.{shape} left {above:0.#} above the caption and {below:0.#} below it.")));
}
///
/// The instrument's own calibration: the check fails on the arrangement the fix replaced.
///
///
/// The same practice states for the clipping harness, and it earns its
/// place here for the same reason. Everything above passes if VerticalContentAlignment is Center
/// AND it passes if the caption happens to fill its box, so without this a rule quietly reverted to
/// Stretch would have to be caught by eye again. Stretch is set inline here rather than by editing the
/// style sheet, because Application.Styles is global and a test that mutated it would be changing
/// every other test in the assembly out from under itself.
///
[Fact]
public async Task AStretchedCaption_IsNotCentred_AndIsCaught()
{
await MeasureAsync(
"ghost",
VerticalAlignment.Stretch,
(above, below) => (below - above).ShouldBeGreaterThan(
Tolerance,
"the caption should sit high, which is the defect this suite was written for"));
}
///
/// flat and cat are excluded from the rule above, and must stay excluded.
///
///
///
/// Both stretch their content on purpose, and both would be silently broken by a later pass that
/// "finished" the sweep the rest of these classes belong to — which is exactly why this is a test and
/// not a comment.
///
///
/// flat carries the titlebar's search pill, a Border.searchpill with no height of its own
/// that is meant to fill all 35 pixels of the button; the usage states
/// HorizontalContentAlignment="Stretch" and relies on the vertical default matching it. Centring
/// from the style would shrink that pill to its caption's line box inside a button twice as tall.
/// cat carries the keychain rail's accent strip, a Border.rowmark whose style sets
/// Width="2" and no height at all — "at full row height", says the rule's own remark — so its
/// height is the stretch and nothing else.
///
///
/// Asserted as "the content fills the button", not as "the caption is off-centre": what these two need
/// is the fill, and a test phrased the other way would still pass if the fill broke in some new way.
///
///
[Theory]
[InlineData("flat")]
[InlineData("cat")]
public async Task AStretchingShapeStillFillsItsButton(string shape)
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
// A bare Border is what both of them actually hold: no height, sized only by its parent.
var fill = new Border();
var button = new Button { Content = fill, Height = FixedHeight };
button.Classes.Add(shape);
var window = LayoutHarness.HostAtMinimumSize(
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// The slot read off the presenter rather than recomputed from the button's Padding:
// these shapes differ in whether their presenter also draws a border, and a hand-rolled
// sum was two pixels out on Button.cat for exactly that reason.
var presenter = button.GetVisualDescendants()
.OfType()
.Single(p => string.Equals(p.Name, "PART_ContentPresenter", StringComparison.Ordinal));
var slot = presenter.Bounds.Height
- presenter.Padding.Top - presenter.Padding.Bottom
- presenter.BorderThickness.Top - presenter.BorderThickness.Bottom;
fill.Bounds.Height.ShouldBe(
slot,
Tolerance,
$"Button.{shape} must stretch its content — the search pill and the rail's accent "
+ "strip have no height of their own");
}
finally
{
window.Close();
}
},
Token);
}
private static Task MeasureAsync(
string shape, VerticalAlignment? alignment, Action assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var button = new Button { Content = "Group ▾", Height = FixedHeight };
button.Classes.Add(shape);
if (alignment is { } forced)
{
button.VerticalContentAlignment = forced;
}
var window = LayoutHarness.HostAtMinimumSize(
button, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
var caption = button.GetVisualDescendants().OfType().Single();
var origin = caption.TranslatePoint(default, button);
origin.ShouldNotBeNull("the caption is not in the button's visual tree");
// The laid-out line rather than caption.Bounds.Height, which under Stretch is the
// content box and so is symmetrical whether or not the ink in it is — see the remark on
// the class.
var line = caption.TextLayout.Height;
var above = origin.Value.Y;
var below = button.Bounds.Height - above - line;
assert(above, below);
}
finally
{
window.Close();
}
},
Token);
}