Files
PowerToys/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/FastFirstPaintTests.cs
Michael Jolley 888a142724 [CmdPal] Consolidate search ranking changes (#49832)
## What's going on

The stacked pull requests are blocked by GitHub's stack merge flow. This
gives the full remaining search ranking change set one PR against
`main`.

## The plan

- Consolidates the open work from #49190, #49191, #49194, #49195,
#49197, #49246, #49247, and #49249.
- Keeps the existing stack unchanged while this PR provides an alternate
merge path.

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5459b847-afeb-4163-a803-977759dd92df
Copilot-Session: 49185697-186e-406f-b081-8c985c134274
Copilot-Session: 92905c83-2de6-449c-b4a1-a08003fe2576
Copilot-Session: efe987e5-9297-47fc-af99-2f49b057285a
Copilot-Session: f42917e2-d298-4bde-9056-79a9e6e17dfa
2026-08-12 11:18:10 -05:00

216 lines
9.3 KiB
C#

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Guardrail tests for the "fast first paint" behavior of the main/root page.
///
/// The typing path renders deterministic in-proc results (top-level commands + installed
/// apps) immediately and folds in slow, out-of-proc fallback contributions asynchronously
/// as they resolve. These tests pin the two invariants that make that safe:
/// 1. Deterministic results are produced without any fallback contribution present, so
/// first paint never waits on a slow source.
/// 2. Late-arriving fallbacks are merged with a fresh score from the same ranker, always
/// at the FallbackFloor tier, so they can never leapfrog deterministic matches, and a
/// superseding query's snapshot replaces any stale one.
/// </summary>
[TestClass]
public sealed partial class FastFirstPaintTests
{
private static readonly Separator _resultsSeparator = new("Results");
private static readonly Separator _fallbacksSeparator = new("Fallbacks");
// A list item whose Title can be mutated to simulate an extension resolving a dynamic
// fallback title asynchronously, after first paint.
private sealed partial class MutableListItem : IListItem
{
public string Title { get; set; } = string.Empty;
public string Subtitle { get; set; } = string.Empty;
public ICommand Command => new NoOpCommand();
public IDetails? Details => null;
public IIconInfo? Icon => null;
public string Section => string.Empty;
public ITag[] Tags => [];
public string TextToSuggest => string.Empty;
public IContextItem[] MoreCommands => [];
#pragma warning disable CS0067 // The event is never used
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
#pragma warning restore CS0067 // The event is never used
public override string ToString() => Title;
}
// A stand-in ranker that mirrors the real contract for fallbacks: an item with no
// (resolved) title scores 0 and is dropped; otherwise it lands at the FallbackFloor tier
// with a stronger within-floor score when its current title overlaps the query text.
private static ScoringFunction<IListItem> FloorScorerFor(string queryText)
{
return (in FuzzyQuery _, IListItem item) =>
{
var title = item.Title;
if (string.IsNullOrWhiteSpace(title))
{
return 0;
}
var within = title.Contains(queryText, StringComparison.OrdinalIgnoreCase) ? 100 : 1;
return MainListRanker.Pack(RankTier.FallbackFloor, within);
};
}
private static RoScored<IListItem> ScoredFuzzy(string title, int within)
=> new(score: MainListRanker.Pack(RankTier.Fuzzy, within), item: new MutableListItem { Title = title });
private static RoScored<IListItem> ScoredFloorMax(string title)
=> new(score: MainListRanker.Pack(RankTier.FallbackFloor, MainListRanker.TierStride - 1), item: new MutableListItem { Title = title });
[TestMethod]
public void FirstPaint_ProducesDeterministicResults_WithNoFallbackContribution()
{
var command = ScoredFuzzy("Notepad", within: 50);
var app = ScoredFuzzy("Notepad++", within: 40);
var filtered = new List<RoScored<IListItem>> { command };
var apps = new List<RoScored<IListItem>> { app };
// No scored fallbacks and no fallback items: this models first paint before any slow
// out-of-proc source has responded.
var result = MainListPageResultFactory.Create(
filtered,
scoredFallbackItems: null,
apps,
fallbackItems: null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
CollectionAssert.AreEqual(
new IListItem[] { _resultsSeparator, command.Item, app.Item },
result,
"First paint must render the deterministic command/app results without any fallback contribution.");
}
[TestMethod]
public void UnresolvedFallback_IsAbsentAtFirstPaint()
{
var fallback = new MutableListItem { Title = string.Empty };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
var scored = MainListPage.ScoreDeferredFallbacks(sources, default, FloorScorerFor("remote"));
Assert.IsNull(scored, "A fallback whose dynamic title has not resolved yet must not appear.");
}
[TestMethod]
public void SlowFallback_FoldsIn_WhenItsTitleResolves()
{
var fallback = new MutableListItem { Title = string.Empty };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
var scorer = FloorScorerFor("remote");
// First paint: unresolved title -> not present.
Assert.IsNull(MainListPage.ScoreDeferredFallbacks(sources, default, scorer));
// The extension resolves the dynamic title asynchronously (off the typing path).
fallback.Title = "Remote Desktop: server01";
// A later refresh re-scores the same snapshot and folds the fallback in.
var after = MainListPage.ScoreDeferredFallbacks(sources, default, scorer);
Assert.IsNotNull(after);
Assert.AreEqual(1, after!.Count);
Assert.AreSame(fallback, after[0].Item);
Assert.AreEqual(RankTier.FallbackFloor, MainListRanker.TierOf(after[0].Score));
}
[TestMethod]
public void ReScore_ReflectsCurrentTitle_NotAFrozenValue()
{
var fallback = new MutableListItem { Title = "no match here" };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
var scorer = FloorScorerFor("remote");
var weak = MainListPage.ScoreDeferredFallbacks(sources, default, scorer);
Assert.IsNotNull(weak);
var weakScore = weak![0].Score;
// The title resolves to a strong match. If scoring were frozen at keystroke time, the
// weak score would persist; deferred re-scoring must reflect the fresh title.
fallback.Title = "Remote host";
var strong = MainListPage.ScoreDeferredFallbacks(sources, default, scorer);
Assert.IsNotNull(strong);
Assert.IsTrue(strong![0].Score > weakScore, "Re-scoring must reflect the freshly resolved title, not a frozen value.");
}
[TestMethod]
public void ReScoreUsesLatestSnapshot_StaleStrongMatchNotApplied()
{
// This exercises the render-path re-score directly: it always scores whatever snapshot is
// current, so a superseding keystroke's query wins and the prior query's strong score is
// gone. In production the field pair (_globalFallbackSources + _globalFallbackQuery) is
// written under lock (commands) in UpdateSearchTextCore and read under the same lock in
// GetItems, so the swap is atomic; this test stands in for that behavior at the helper level.
var fallback = new MutableListItem { Title = "Remote Desktop" };
IReadOnlyList<IListItem> sources = new IListItem[] { fallback };
// Query A ("remote") is a strong match for the current title.
var a = MainListPage.ScoreDeferredFallbacks(sources, default, FloorScorerFor("remote"));
Assert.IsNotNull(a);
var strongScore = a![0].Score;
// A newer keystroke installs query B ("zzz"), which does not overlap the title. The
// render path always scores the latest snapshot, so query A's strong score is gone.
var b = MainListPage.ScoreDeferredFallbacks(sources, default, FloorScorerFor("zzz"));
Assert.IsNotNull(b);
Assert.IsTrue(b![0].Score < strongScore, "A superseding query must not inherit the prior query's stale score.");
}
[TestMethod]
public void LateFallback_MergesBelowDeterministicMatches_NoLeapfrog()
{
// A minimal deterministic Fuzzy match versus a fallback with the maximum possible
// within-floor score. The tier ladder must still keep the fallback below the command.
var command = ScoredFuzzy("Notepad", within: 1);
var fallback = ScoredFloorMax("Search the web for notepad");
var filtered = new List<RoScored<IListItem>> { command };
var scoredFallbacks = new List<RoScored<IListItem>> { fallback };
var result = MainListPageResultFactory.Create(
filtered,
scoredFallbacks,
filteredApps: null,
fallbackItems: null,
_resultsSeparator,
_fallbacksSeparator,
appResultLimit: 10);
Assert.AreEqual(_resultsSeparator, result[0]);
Assert.AreSame(command.Item, result[1], "Deterministic matches must sort above floor-tier fallbacks.");
Assert.AreSame(fallback.Item, result[2], "A late floor-tier fallback merges after deterministic results, never leapfrogging them.");
}
}