Merge pull request 'Name a quick-connect result's vault, and search on it' (#13) from claude/search-items-vault-display-865eb0 into main
ci / build and test (push) Successful in 3m42s
ci / api image (push) Successful in 37s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Successful in 44s

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2026-08-14 09:13:16 +00:00
5 changed files with 215 additions and 6 deletions
@@ -236,6 +236,51 @@ public sealed class QuickConnectTests : IAsyncLifetime
});
}
/// <remarks>
/// The palette searches every vault the session holds a key for, so a row has to say which one it came
/// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
/// and Enter takes whichever the ranking happened to put first.
/// <para>
/// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
/// active vault's rows come first — and the list virtualises, so the row this is about might never be
/// realised. Narrowing to it also proves the search reaches past the active vault at all.
/// </para>
/// </remarks>
[Fact]
public async Task AResultSaysWhichVaultItCameOutOf()
{
await SeedSharedHostAsync();
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "prod-db";
Relayout(window);
var found = shell.SearchResults.ShouldHaveSingleItem();
found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");
VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
});
}
/// <remarks>
/// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
/// every row of a session that has only one is the same fact repeated, which is noise rather than a
/// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
/// than leave a gap above the kind word.
/// </remarks>
[Fact]
public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
{
await OnThePaletteAsync((palette, _) =>
{
var first = shell.SearchResults[0];
first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
});
}
/// <remarks>
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
@@ -307,6 +352,25 @@ public sealed class QuickConnectTests : IAsyncLifetime
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
private static List<string> VisibleTexts(Visual row) =>
row.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.IsEffectivelyVisible)
.Select(text => text.Text ?? string.Empty)
.ToList();
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
/// <remarks>
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
/// finds nothing to look at.
/// </remarks>
private static void Relayout(Window window)
{
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
}
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
@@ -325,4 +389,37 @@ public sealed class QuickConnectTests : IAsyncLifetime
await vault.LoadAsync(Token);
}
/// <summary>
/// Adds a second, shared vault to the fixture's session and files one host into it.
/// </summary>
/// <remarks>
/// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
/// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
/// shell has none. The key is generated on this machine either way, so what the session ends up holding
/// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
/// </remarks>
private async Task SeedSharedHostAsync()
{
using var teamServer = new StubTeamServer();
var shared = await session.CreateTeamVaultAsync(
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
await vault.LoadAsync(Token);
}
}