Files
DodoSSH/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs
T
jaap-jan 0b261c4d39 Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes
Enter, which is the gesture everybody makes after typing a password and which
did nothing until they found the button.

Signing in survives a relaunch. The refresh token is kept in the local cache,
sealed under the vault's own cache key, so a later launch resumes the session
through the refresh grant with no browser and nobody present — and because it
is sealed under that key, only an unlocked vault can resume it. A locked
client therefore cannot reach the server at all, which is a consequence worth
stating rather than working around; docs/crypto.md §3.2 records it. Every sync
pass asks the shell for a connection rather than reading one captured at
unlock, so a laptop that unlocked on a train is online within a minute of
finding a network, with nothing pressed. Unlocking itself still never waits on
a socket.

Signing out empties this machine: the profile, the cached items, the outbox
and this machine's device key, with the account's row withdrawn when the
server can be reached. It asks first and says what it costs — the outbox count
when the vault is open, an admission that it cannot be counted when it is not,
and the shells that keep running either way. The vault is on the server and is
untouched, which is what makes the same button the only honest answer to a
forgotten passphrase, so it is on the unlock screen as well as in preferences.
It cannot end the session at the identity provider, and says so.

Two defects surfaced on the way. The synchronisation pass that runs when the
vault opens never ran at all: the loop is started from inside the unlock
command, so the busy flag it yields to was raised by that command — the first
sync was a minute late on every launch. And signing in from preferences while
unlocked threw an unlock screen over an open vault whose keys were still in
memory.

The unlock card and the new confirmation live in their own controls because
MainWindow cannot be laid out headless, so markup left inside it is markup no
test can measure; both are now measured at the window's minimum size in the
shapes that grow. What is still unverified is the composed window itself.
2026-07-31 11:07:36 +02:00

194 lines
6.7 KiB
C#

using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.Views;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The harness measuring itself.
/// </summary>
/// <remarks>
/// A clipping detector that never fires is worse than no detector, because it reads as a guarantee. So the
/// deliberately-broken window below is the most important test in this file: it is the one that proves the
/// green ones mean something. This is the same practice §5 of the handoff describes — a test proves nothing
/// until it has been seen to fail — applied to the instrument rather than to the code.
/// </remarks>
public sealed class LayoutHarnessTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task AWindowWithRoomToSpare_ReportsNothing()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = LayoutHarness.HostAtMinimumSize(
new Button { Content = "Save" },
LayoutHarness.MinimumWidth,
LayoutHarness.MinimumHeight);
try
{
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task AButtonPushedPastTheBottomEdge_IsReportedByName()
{
// The instrument's own calibration. A stack taller than its window is exactly the shape of the
// defect the vault column is one editor away from, and if this passes silently the harness is
// decoration.
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var stack = new StackPanel();
for (var i = 0; i < 8; i++)
{
stack.Children.Add(new Button { Content = i == 7 ? "Save" : $"filler {i}" });
}
var window = LayoutHarness.HostAtMinimumSize(stack, 300, 120);
try
{
var faults = LayoutHarness.Unreachable(window);
faults.ShouldNotBeEmpty();
faults.ShouldContain(fault => fault.Contains("'Save'", StringComparison.Ordinal));
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task AListLongerThanItsViewport_IsNotAFault()
{
// The exemption that keeps this harness usable. Scrolling is how a list is supposed to handle more
// rows than fit; without this the host list would fail the moment it had content.
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var stack = new StackPanel();
for (var i = 0; i < 40; i++)
{
stack.Children.Add(new Button { Content = $"row {i}" });
}
var window = LayoutHarness.HostAtMinimumSize(
new ScrollViewer { Content = stack },
300,
120);
try
{
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task TheWholeWindowsXamlParses()
{
// Constructing it runs InitializeComponent, so this is what catches malformed XAML, a style selector
// that no longer resolves or a converter reference that has gone stale. Cheap, and it covers the
// whole file including the setup cards no other test here touches.
//
// Constructed and never shown, deliberately — see WhyTheWindowItselfIsNeverShown.
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = new MainWindow();
try
{
window.Content.ShouldNotBeNull();
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task WhyTheWindowItselfIsNeverShown()
{
// Measured, not assumed, and pinned here so nobody spends an afternoon rediscovering it.
//
// Showing MainWindow attaches the terminal's NativeWebView, whose Win32 adapter initialises WebView2
// on attach — and WebView2 refuses an MTA thread, which is exactly why Program.Main is [STAThread].
// A HeadlessUnitTestSession owns its dispatcher thread and does not offer an apartment choice, so
// the whole window cannot be laid out here at any size.
//
// That is the reason this harness measures the extracted controls rather than MainWindow: each of
// them is a part with a height budget to blow, and none of them has a native child window in it. If a
// future Avalonia makes the adapter lazy, this test starts failing and the harness can be widened.
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = new MainWindow();
try
{
var showing = Should.Throw<COMException>(() => LayoutHarness.Settle(
window,
LayoutHarness.MinimumWidth,
LayoutHarness.MinimumHeight));
// RPC_E_CHANGED_MODE. Asserted on the code rather than the message so a localised
// Windows does not break the build.
showing.HResult.ShouldBe(unchecked((int)0x80010106));
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task TheHarnessMeasuresTheSizeTheWindowDeclares()
{
// Pins the two constants against the XAML. A harness measuring a size the window lets itself
// be dragged to something smaller would be certifying a size no user is held to.
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var window = new MainWindow();
try
{
window.MinWidth.ShouldBe(LayoutHarness.MinimumWidth);
window.MinHeight.ShouldBe(LayoutHarness.MinimumHeight);
}
finally
{
window.Close();
}
},
Token);
}
}