Merge branch 'claude/host-detail-pane-design-e98621'

This commit is contained in:
2026-08-03 16:31:43 +02:00
7 changed files with 1219 additions and 382 deletions
@@ -346,6 +346,70 @@ public sealed class HostGridTests : IAsyncLifetime
vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
}
/// <summary>
/// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
/// </summary>
/// <remarks>
/// <para>
/// The two halves are one rule and are asserted together, because either alone would pass on a broken
/// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the
/// second. What is being held is that opening is <em>deliberate</em>.
/// </para>
/// <para>
/// Driven through the card's own button rather than by executing the command, since the thing most
/// likely to rot is the binding that reaches out of the item template to the vault's command — a
/// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
/// </para>
/// </remarks>
[Fact]
public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
{
await OnTheGridAsync((screen, _) =>
{
var host = Row(vault, "stage-web");
vault.SelectedHost = host;
vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane");
// The button is hidden until the pointer is on the card, so a click cannot be synthesised at a
// point: what a headless run can reach is the control and the command behind it.
var pencil = CardFor(screen, host)
.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("rowedit"));
pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
pencil.Command.Execute(pencil.CommandParameter);
vault.IsDrawerOpen.ShouldBeTrue();
vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
});
}
/// <remarks>
/// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
/// selection that goes away entirely has to take it with it. Without that the flag would survive a
/// filter matching nothing, and the drawer would spring open again on the next card merely selected,
/// which is the behaviour the pencil exists to remove.
/// </remarks>
[Fact]
public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
{
await OnTheGridAsync((_, _) =>
{
vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
vault.IsDrawerOpen.ShouldBeTrue();
vault.SelectedHost = null;
vault.IsDrawerOpen.ShouldBeFalse();
vault.SelectedHost = Row(vault, "stage-web");
vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
});
}
// ---- Helpers ----
/// <summary>The same in-process format the screen's own drag carries.</summary>
@@ -143,10 +143,17 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
// the right.
/// <remarks>
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
/// when the pencil arrived: a selected host no longer puts the pane up, so a test that only selected one
/// would measure a drawer with all three panels collapsed and pass on an empty column. See
/// <c>VaultViewModel.IsHostPaneOpen</c>.
/// </remarks>
[Fact]
public async Task TheHostDrawerFitsShowingAHost()
{
vault.SelectedHost = vault.Hosts[0];
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsShowingHostDetail.ShouldBeTrue("there is nothing to measure otherwise");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
@@ -206,8 +213,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
[Fact]
public async Task TheHostDrawerFitsWithADeletionInQuestion()
{
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHost.IsConnected = true;
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.SelectedHost.ShouldNotBeNull().IsConnected = true;
vault.DeleteHostCommand.Execute(null);
vault.IsConfirmingDeletion.ShouldBeTrue();
@@ -373,7 +380,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
@@ -399,7 +406,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
[Fact]
public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
await OnTheHostsScreenAsync((screen, window) =>