mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-08-29 01:59:34 +02:00
[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
This commit is contained in:
@@ -145,6 +145,8 @@ Thank you for using PowerToys!
|
|||||||
| Microsoft.PowerToys.CmdPalHotkeySummoned | Logs when the Command Palette is summoned via hotkey, distinguishing between global and context-specific hotkeys. |
|
| Microsoft.PowerToys.CmdPalHotkeySummoned | Logs when the Command Palette is summoned via hotkey, distinguishing between global and context-specific hotkeys. |
|
||||||
| Microsoft.PowerToys.CmdPalInvokeResult | Records the result type of a Command Palette invocation. |
|
| Microsoft.PowerToys.CmdPalInvokeResult | Records the result type of a Command Palette invocation. |
|
||||||
| Microsoft.PowerToys.CmdPalProcessStarted | Triggered when the Command Palette process is started. |
|
| Microsoft.PowerToys.CmdPalProcessStarted | Triggered when the Command Palette process is started. |
|
||||||
|
| Microsoft.PowerToys.CmdPal_SearchResults | Triggered when a search is triggered on the main list, including query length, result count, and duration. |
|
||||||
|
| Microsoft.PowerToys.CmdPal_SearchResultSelected | Triggered when a search result is selected, including query length, selection index, and result tier. |
|
||||||
|
|
||||||
### Crop and lock
|
### Crop and lock
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,84 @@ public static partial class InternalListHelpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Minimum item count before the parallel path earns back its partitioning and merge overhead,
|
||||||
|
// which keeps commands and small app sets on the serial path.
|
||||||
|
private const int ParallelScoringThreshold = 512;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Order-preserving parallel variant of <see cref="FilterListWithScores{T}"/> that scores
|
||||||
|
/// contiguous index ranges on separate threads and concatenates them back in partition order,
|
||||||
|
/// producing a pre-sort buffer identical to the serial path. The scoring function has to be
|
||||||
|
/// pure per item, since each item is scored by exactly one thread.
|
||||||
|
/// </summary>
|
||||||
|
public static RoScored<T>[] FilterListWithScoresParallel<T>(
|
||||||
|
IReadOnlyList<T>? items,
|
||||||
|
in FuzzyQuery query,
|
||||||
|
in ScoringFunction<T> scoreFunction)
|
||||||
|
{
|
||||||
|
if (items is null || items.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = items.Count;
|
||||||
|
var partitions = Math.Min(Environment.ProcessorCount, Math.Max(1, count / ParallelScoringThreshold));
|
||||||
|
|
||||||
|
if (count < ParallelScoringThreshold || partitions <= 1)
|
||||||
|
{
|
||||||
|
return FilterListWithScores(items, query, scoreFunction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the by-ref parameters into locals so the parallel body can capture them. FuzzyQuery
|
||||||
|
// is immutable, so one shared copy is safe to read from every thread.
|
||||||
|
var q = query;
|
||||||
|
var fn = scoreFunction;
|
||||||
|
var source = items;
|
||||||
|
|
||||||
|
var partitionResults = new List<RoScored<T>>[partitions];
|
||||||
|
|
||||||
|
System.Threading.Tasks.Parallel.For(0, partitions, p =>
|
||||||
|
{
|
||||||
|
var start = (int)((long)p * count / partitions);
|
||||||
|
var end = (int)((long)(p + 1) * count / partitions);
|
||||||
|
|
||||||
|
var local = new List<RoScored<T>>(end - start);
|
||||||
|
for (var i = start; i < end; i++)
|
||||||
|
{
|
||||||
|
var item = source[i];
|
||||||
|
var score = fn(in q, item);
|
||||||
|
if (score > 0)
|
||||||
|
{
|
||||||
|
local.Add(new RoScored<T>(item, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partitionResults[p] = local;
|
||||||
|
});
|
||||||
|
|
||||||
|
var total = 0;
|
||||||
|
for (var p = 0; p < partitions; p++)
|
||||||
|
{
|
||||||
|
total += partitionResults[p].Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contiguous ranges merged in partition order reproduce the serial buffer's exact
|
||||||
|
// enumeration order.
|
||||||
|
var buffer = GC.AllocateUninitializedArray<RoScored<T>>(total);
|
||||||
|
var pos = 0;
|
||||||
|
for (var p = 0; p < partitions; p++)
|
||||||
|
{
|
||||||
|
var list = partitionResults[p];
|
||||||
|
for (var j = 0; j < list.Count; j++)
|
||||||
|
{
|
||||||
|
buffer[pos++] = list[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.Sort(buffer, 0, total, default(RoScoredDescendingComparer<T>));
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
private static void GrowBuffer<T>(ref RoScored<T>[] buffer, int count)
|
private static void GrowBuffer<T>(ref RoScored<T>[] buffer, int count)
|
||||||
{
|
{
|
||||||
var newBuffer = ArrayPool<RoScored<T>>.Shared.Rent(buffer.Length * 2);
|
var newBuffer = ArrayPool<RoScored<T>>.Shared.Rent(buffer.Length * 2);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#define CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
#define CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
@@ -49,6 +50,10 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
|
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
|
||||||
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
|
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
|
||||||
|
|
||||||
|
// All main-page search telemetry state and emission is owned by this dedicated type, keeping
|
||||||
|
// MainListPage responsible for producing results rather than for tracking telemetry bookkeeping.
|
||||||
|
private readonly MainListPageSearchTelemetry _searchTelemetry = new();
|
||||||
|
|
||||||
// Stable separator instances so that the VM cache and InPlaceUpdateList
|
// Stable separator instances so that the VM cache and InPlaceUpdateList
|
||||||
// recognise them across successive GetItems() calls
|
// recognise them across successive GetItems() calls
|
||||||
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
|
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
|
||||||
@@ -63,16 +68,34 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
private RoScored<IListItem>[]? _filteredItems;
|
private RoScored<IListItem>[]? _filteredItems;
|
||||||
private RoScored<IListItem>[]? _filteredApps;
|
private RoScored<IListItem>[]? _filteredApps;
|
||||||
|
|
||||||
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
|
// Published with _filteredApps so filtering uses the query that produced the scores.
|
||||||
// asynchronously, so scoring must happen lazily when GetItems is called.
|
private int _filteredAppsQueryLength;
|
||||||
private IEnumerable<RoScored<IListItem>>? _scoredFallbackItems;
|
|
||||||
|
// Global/special fallbacks are scored on the render path, not at keystroke time, because
|
||||||
|
// their titles resolve asynchronously. We snapshot the source list and query together so a
|
||||||
|
// superseding keystroke replaces both atomically.
|
||||||
|
private IReadOnlyList<IListItem>? _globalFallbackSources;
|
||||||
|
private FuzzyQuery _globalFallbackQuery;
|
||||||
|
|
||||||
|
// Common fallbacks use query-independent scores, so freezing them is safe; only their live
|
||||||
|
// titles decide whether they render.
|
||||||
private IEnumerable<RoScored<IListItem>>? _fallbackItems;
|
private IEnumerable<RoScored<IListItem>>? _fallbackItems;
|
||||||
|
|
||||||
private bool _includeApps;
|
private bool _includeApps;
|
||||||
private bool _filteredItemsIncludesApps;
|
private bool _filteredItemsIncludesApps;
|
||||||
|
|
||||||
|
// Last per-provider settings we reacted to, so a settings reload can tell whether any
|
||||||
|
// provider's search weight actually changed and only then re-rank the active query.
|
||||||
|
private ImmutableDictionary<string, ProviderSettings>? _lastProviderSettingsSnapshot;
|
||||||
|
|
||||||
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
|
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
|
||||||
|
|
||||||
|
// Longest query to filter fuzzy app matches on. This prevents weak app matches on short queries.
|
||||||
|
private const int ShortQueryAppFilterMaxLength = 2;
|
||||||
|
|
||||||
|
// Minimum tier an app must reach to appear for a short query.
|
||||||
|
private const RankTier ShortQueryAppFilterMinTier = RankTier.AcronymWordBoundary;
|
||||||
|
|
||||||
private InterlockedBoolean _fullRefreshRequested;
|
private InterlockedBoolean _fullRefreshRequested;
|
||||||
private InterlockedBoolean _refreshRunning;
|
private InterlockedBoolean _refreshRunning;
|
||||||
private InterlockedBoolean _refreshRequested;
|
private InterlockedBoolean _refreshRequested;
|
||||||
@@ -100,7 +123,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
_appStateService = appStateService;
|
_appStateService = appStateService;
|
||||||
_tlcManager = topLevelCommandManager;
|
_tlcManager = topLevelCommandManager;
|
||||||
_fuzzyMatcherProvider = fuzzyMatcherProvider;
|
_fuzzyMatcherProvider = fuzzyMatcherProvider;
|
||||||
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current);
|
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateService.State.RecentCommands, _fuzzyMatcherProvider.Current, ResolveProviderSearchWeight);
|
||||||
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settingsService.Settings.FallbackRanks);
|
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settingsService.Settings.FallbackRanks);
|
||||||
|
|
||||||
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
|
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
|
||||||
@@ -259,22 +282,125 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
|
|
||||||
private IListItem[] GetSearchViewItems()
|
private IListItem[] GetSearchViewItems()
|
||||||
{
|
{
|
||||||
var validScoredFallbacks = _scoredFallbackItems?
|
// Score global fallbacks against their current titles so a fallback whose title
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
// resolved after first paint gets the right score. Cheap: only a handful are configured.
|
||||||
.ToList();
|
var validScoredFallbacks = ScoreDeferredFallbacks(_globalFallbackSources, _globalFallbackQuery, _scoringFunction);
|
||||||
|
|
||||||
var validFallbacks = _fallbackItems?
|
var validFallbacks = _fallbackItems?
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return MainListPageResultFactory.Create(
|
// Remove fuzzy-only app matches for short queries so frecency-boosted weak matches don't
|
||||||
|
// appear while typing.
|
||||||
|
var filteredApps = FilterAppsForShortQueries(_filteredApps, _filteredAppsQueryLength);
|
||||||
|
|
||||||
|
var result = MainListPageResultFactory.Create(
|
||||||
_filteredItems,
|
_filteredItems,
|
||||||
validScoredFallbacks,
|
validScoredFallbacks,
|
||||||
_filteredApps,
|
filteredApps,
|
||||||
validFallbacks,
|
validFallbacks,
|
||||||
_resultsSeparator,
|
_resultsSeparator,
|
||||||
_fallbacksSeparator,
|
_fallbacksSeparator,
|
||||||
AppResultLimit);
|
AppResultLimit);
|
||||||
|
|
||||||
|
// Snapshot the rendered order plus every scored input and the query length together, so
|
||||||
|
// selection telemetry resolves an invoked item's rank, tier, and query length from this one
|
||||||
|
// generation off the hot path. These are plain reference assignments - no extra allocation.
|
||||||
|
_searchTelemetry.CaptureSearchView(
|
||||||
|
result,
|
||||||
|
_filteredItems,
|
||||||
|
_filteredApps,
|
||||||
|
validScoredFallbacks,
|
||||||
|
_fallbackItems,
|
||||||
|
SearchText?.Length ?? 0);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scores the current global-fallback snapshot against its query, dropping any whose title is
|
||||||
|
// still empty. Static so it can be unit tested with a fake slow source.
|
||||||
|
internal static List<RoScored<IListItem>>? ScoreDeferredFallbacks(
|
||||||
|
IReadOnlyList<IListItem>? sources,
|
||||||
|
in FuzzyQuery query,
|
||||||
|
ScoringFunction<IListItem> scoringFunction)
|
||||||
|
{
|
||||||
|
if (sources is null || sources.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scored = InternalListHelpers.FilterListWithScores(sources, query, scoringFunction);
|
||||||
|
if (scored.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RoScored<IListItem>>? valid = null;
|
||||||
|
foreach (var s in scored)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(s.Item.Title))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
valid ??= new List<RoScored<IListItem>>(scored.Length);
|
||||||
|
valid.Add(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a filtered view of the scored array without reordering it.
|
||||||
|
internal static IList<RoScored<IListItem>>? FilterAppsForShortQueries(
|
||||||
|
RoScored<IListItem>[]? scoredApps,
|
||||||
|
int queryLength)
|
||||||
|
{
|
||||||
|
if (scoredApps is null || scoredApps.Length == 0)
|
||||||
|
{
|
||||||
|
return scoredApps;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryLength <= 0 || queryLength > ShortQueryAppFilterMaxLength)
|
||||||
|
{
|
||||||
|
return scoredApps;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keep = GetHighConfidenceAppsCount(scoredApps, ShortQueryAppFilterMinTier);
|
||||||
|
return keep == scoredApps.Length
|
||||||
|
? scoredApps
|
||||||
|
: new ArraySegment<RoScored<IListItem>>(scoredApps, 0, keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qualifying apps form a contiguous prefix because the array is already sorted by score.
|
||||||
|
internal static int GetHighConfidenceAppsCount(IReadOnlyList<RoScored<IListItem>> scored, RankTier minTier)
|
||||||
|
{
|
||||||
|
var min = (int)minTier;
|
||||||
|
for (var i = 0; i < scored.Count; i++)
|
||||||
|
{
|
||||||
|
if ((int)MainListRanker.TierOf(scored[i].Score) < min)
|
||||||
|
{
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scored.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies the short-query filter and result limit to the telemetry count.
|
||||||
|
internal static int GetVisibleAppCount(RoScored<IListItem>[]? scoredApps, int queryLength, int appResultLimit)
|
||||||
|
{
|
||||||
|
if (scoredApps is null || scoredApps.Length == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = scoredApps.Length;
|
||||||
|
if (queryLength > 0 && queryLength <= ShortQueryAppFilterMaxLength)
|
||||||
|
{
|
||||||
|
count = GetHighConfidenceAppsCount(scoredApps, ShortQueryAppFilterMinTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Min(count, appResultLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IListItem[] GetDefaultViewItems()
|
private IListItem[] GetDefaultViewItems()
|
||||||
@@ -361,8 +487,12 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
{
|
{
|
||||||
_filteredItems = null;
|
_filteredItems = null;
|
||||||
_filteredApps = null;
|
_filteredApps = null;
|
||||||
|
_filteredAppsQueryLength = 0;
|
||||||
_fallbackItems = null;
|
_fallbackItems = null;
|
||||||
_scoredFallbackItems = null;
|
_globalFallbackSources = null;
|
||||||
|
|
||||||
|
// Clear the paired query too, so both are reset together.
|
||||||
|
_globalFallbackQuery = default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||||
@@ -403,6 +533,10 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
|
|
||||||
if (aliases.CheckAlias(newSearch))
|
if (aliases.CheckAlias(newSearch))
|
||||||
{
|
{
|
||||||
|
// An alias query supersedes any normal query whose settled-search telemetry is
|
||||||
|
// still pending in the debounce; drop it so the superseded query never emits.
|
||||||
|
_searchTelemetry.CancelPendingResults();
|
||||||
|
|
||||||
if (_filteredItemsIncludesApps != _includeApps)
|
if (_filteredItemsIncludesApps != _includeApps)
|
||||||
{
|
{
|
||||||
lock (_tlcManager.TopLevelCommands)
|
lock (_tlcManager.TopLevelCommands)
|
||||||
@@ -422,6 +556,17 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var commands = _tlcManager.TopLevelCommands;
|
var commands = _tlcManager.TopLevelCommands;
|
||||||
|
|
||||||
|
// Inputs captured under the lock so the heavy scoring below can run off it. GetItems()
|
||||||
|
// takes the same lock, so it now only contends with the short snapshot and publish sections.
|
||||||
|
IReadOnlyList<IListItem> itemsSource;
|
||||||
|
IReadOnlyList<IListItem> appsSource;
|
||||||
|
IReadOnlyList<IListItem> fallbackSource;
|
||||||
|
IListItem[] globalFallbackSources;
|
||||||
|
bool includeAppsSnapshot;
|
||||||
|
bool tookFullCatalog = false;
|
||||||
|
|
||||||
|
// ===== SNAPSHOT PHASE (under lock) =====
|
||||||
lock (commands)
|
lock (commands)
|
||||||
{
|
{
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
@@ -463,58 +608,37 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
{
|
{
|
||||||
_filteredItemsIncludesApps = _includeApps;
|
_filteredItemsIncludesApps = _includeApps;
|
||||||
ClearResults();
|
ClearResults();
|
||||||
|
|
||||||
|
// Drop any pending settled-search telemetry so a cleared query never emits.
|
||||||
|
_searchTelemetry.ClearSearchView();
|
||||||
|
|
||||||
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
|
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
|
||||||
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
|
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the new string doesn't start with the old string, then we can't
|
includeAppsSnapshot = _includeApps;
|
||||||
// re-use previous results. Reset _filteredItems, and keep er moving.
|
|
||||||
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
|
|
||||||
{
|
|
||||||
ClearResults();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the internal state has changed, reset _filteredItems to reset the list.
|
// A query that doesn't extend the old one, or a change in app inclusion, means we
|
||||||
if (_filteredItemsIncludesApps != _includeApps)
|
// can't re-use the previous results and have to rebuild from the full catalog. On an
|
||||||
{
|
// extend we re-score only the previously matched subset.
|
||||||
ClearResults();
|
var reset = !newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase)
|
||||||
}
|
|| _filteredItemsIncludesApps != includeAppsSnapshot;
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
var prevFilteredItems = reset ? null : _filteredItems;
|
||||||
{
|
var prevApps = reset ? null : _filteredApps;
|
||||||
return;
|
var prevFallbacks = reset ? null : _fallbackItems;
|
||||||
}
|
|
||||||
|
|
||||||
var newFilteredItems = Enumerable.Empty<IListItem>();
|
IEnumerable<IListItem> newFilteredItems = prevFilteredItems is not null
|
||||||
var newFallbacks = Enumerable.Empty<IListItem>();
|
? prevFilteredItems.Select(s => s.Item)
|
||||||
var newApps = Enumerable.Empty<IListItem>();
|
: Enumerable.Empty<IListItem>();
|
||||||
|
IEnumerable<IListItem> newApps = prevApps is not null
|
||||||
if (_filteredItems is not null)
|
? prevApps.Select(s => s.Item)
|
||||||
{
|
: Enumerable.Empty<IListItem>();
|
||||||
newFilteredItems = _filteredItems.Select(s => s.Item);
|
IEnumerable<IListItem> newFallbacks = prevFallbacks is not null
|
||||||
}
|
? prevFallbacks.Select(s => s.Item)
|
||||||
|
: Enumerable.Empty<IListItem>();
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_filteredApps is not null)
|
|
||||||
{
|
|
||||||
newApps = _filteredApps.Select(s => s.Item);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_fallbackItems is not null)
|
|
||||||
{
|
|
||||||
newFallbacks = _fallbackItems.Select(s => s.Item);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -525,6 +649,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
// with a list of all our commands & apps.
|
// with a list of all our commands & apps.
|
||||||
if (!newFilteredItems.Any() && !newApps.Any())
|
if (!newFilteredItems.Any() && !newApps.Any())
|
||||||
{
|
{
|
||||||
|
tookFullCatalog = true;
|
||||||
newFilteredItems = commands.Where(s => !s.IsFallback);
|
newFilteredItems = commands.Where(s => !s.IsFallback);
|
||||||
|
|
||||||
// Fallbacks are always included in the list, even if they
|
// Fallbacks are always included in the list, even if they
|
||||||
@@ -537,9 +662,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_filteredItemsIncludesApps = _includeApps;
|
if (includeAppsSnapshot)
|
||||||
|
|
||||||
if (_includeApps)
|
|
||||||
{
|
{
|
||||||
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
|
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
|
||||||
|
|
||||||
@@ -562,71 +685,151 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
|
// Materialize every source while still under the lock, so the scoring passes never
|
||||||
|
// touch the live TopLevelCommands collection or the app provider.
|
||||||
|
itemsSource = MaterializeSource(newFilteredItems);
|
||||||
|
appsSource = MaterializeSource(newApps);
|
||||||
|
fallbackSource = MaterializeSource(newFallbacks);
|
||||||
|
globalFallbackSources = [.. specialFallbacks];
|
||||||
|
}
|
||||||
|
|
||||||
// Produce a list of everything that matches the current filter.
|
if (token.IsCancellationRequested)
|
||||||
_filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SCORING PHASE (off the lock) =====
|
||||||
|
// The dominant apps pass is parallelized, commands and fallbacks stay serial, and none of
|
||||||
|
// it holds the TopLevelCommands lock any more, so it no longer blocks GetItems()/render.
|
||||||
|
//
|
||||||
|
// Snapshot every scoring input once, up front: the live fields can be swapped mid-pass when
|
||||||
|
// a selection calls WithHistoryItem on another thread, which would mix two frecency
|
||||||
|
// snapshots into one pass or race a parallel thread against an unwarmed history index.
|
||||||
|
var recent = _appStateService.State.RecentCommands;
|
||||||
|
recent.PrewarmIndex();
|
||||||
|
var matcher = _fuzzyMatcherProvider.Current;
|
||||||
|
var settings = _settingsService.Settings;
|
||||||
|
var scoringNow = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
// Precompute from the snapshotted newSearch, not the live SearchText, which a newer
|
||||||
|
// keystroke may already have advanced past.
|
||||||
|
var searchQuery = matcher.PrecomputeQuery(newSearch);
|
||||||
|
|
||||||
|
// Every installed app belongs to the well-known AllApps provider, so its weight is constant
|
||||||
|
// for the whole pass and we resolve it once instead of once per app.
|
||||||
|
var appsProviderWeight = ResolveProviderSearchWeight(settings, AllAppsCommandProvider.WellKnownId);
|
||||||
|
Func<IListItem, ProviderSearchWeight> commandsProviderLookup = item => ResolveProviderSearchWeight(settings, item);
|
||||||
|
Func<IListItem, ProviderSearchWeight> appsProviderLookup = _ => appsProviderWeight;
|
||||||
|
|
||||||
|
ScoringFunction<IListItem> commandsScorer = (in FuzzyQuery q, IListItem item) =>
|
||||||
|
ScoreTopLevelItem(in q, item, recent, matcher, commandsProviderLookup, scoringNow);
|
||||||
|
ScoringFunction<IListItem> appsScorer = (in FuzzyQuery q, IListItem item) =>
|
||||||
|
ScoreTopLevelItem(in q, item, recent, matcher, appsProviderLookup, scoringNow);
|
||||||
|
|
||||||
|
var scoredFilteredItems = InternalListHelpers.FilterListWithScores(itemsSource, searchQuery, commandsScorer);
|
||||||
|
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scoredFallbackItems = InternalListHelpers.FilterListWithScores(fallbackSource, searchQuery, _fallbackScoringFunction);
|
||||||
|
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RoScored<IListItem>[]? scoredApps = null;
|
||||||
|
if (appsSource.Count > 0)
|
||||||
|
{
|
||||||
|
scoredApps = InternalListHelpers.FilterListWithScoresParallel(appsSource, searchQuery, appsScorer);
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
if (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && configuredGlobalFallbackIds.Contains(s.Id));
|
|
||||||
_scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
|
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_fallbackItems = InternalListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
|
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Produce a list of filtered apps with the appropriate limit
|
|
||||||
if (newApps.Any())
|
|
||||||
{
|
|
||||||
_filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
|
|
||||||
|
|
||||||
if (token.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||||
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
|
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// ===== PUBLISH PHASE (under lock) =====
|
||||||
|
// The critical section is the field swaps only. Telemetry debounce and refresh throttling
|
||||||
|
// happen after the lock so _searchTelemetryLock never nests under the commands lock.
|
||||||
|
var deterministicResultCount = 0;
|
||||||
|
lock (commands)
|
||||||
|
{
|
||||||
|
// A newer keystroke cancels this token before doing its own work, so a stale snapshot
|
||||||
|
// can never overwrite a newer query's results.
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tookFullCatalog)
|
||||||
|
{
|
||||||
|
_filteredItemsIncludesApps = includeAppsSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
_filteredItems = scoredFilteredItems;
|
||||||
|
_fallbackItems = scoredFallbackItems;
|
||||||
|
|
||||||
|
// Snapshot the global fallbacks and query, but score them later on the render path,
|
||||||
|
// since their titles are still resolving asynchronously (BeginUpdate, above).
|
||||||
|
_globalFallbackSources = globalFallbackSources;
|
||||||
|
_globalFallbackQuery = searchQuery;
|
||||||
|
|
||||||
|
// With no apps source, publish null so a rebuild clears any stale set, matching the old
|
||||||
|
// ClearResults behavior.
|
||||||
|
_filteredApps = appsSource.Count > 0 ? scoredApps : null;
|
||||||
|
|
||||||
|
// Publish the length with the array so filtering and telemetry use the same query.
|
||||||
|
_filteredAppsQueryLength = _filteredApps is null ? 0 : newSearch.Length;
|
||||||
|
|
||||||
if (isUserInput)
|
if (isUserInput)
|
||||||
{
|
{
|
||||||
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
|
deterministicResultCount = (_filteredItems?.Length ?? 0)
|
||||||
// takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
|
+ GetVisibleAppCount(_filteredApps, _filteredAppsQueryLength, AppResultLimit);
|
||||||
var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
|
|
||||||
if (adjustedInterval < TimeSpan.Zero)
|
|
||||||
{
|
|
||||||
adjustedInterval = TimeSpan.Zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
RequestRefresh(fullRefresh: true, adjustedInterval);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RequestRefresh(fullRefresh: true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||||
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
|
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
|
||||||
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
|
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
stopwatch.Stop();
|
// Getting here means the swap happened, since the superseded path returns inside the lock.
|
||||||
|
stopwatch.Stop();
|
||||||
|
|
||||||
|
if (isUserInput)
|
||||||
|
{
|
||||||
|
// Queue a settled-search telemetry event. It's debounced so it only fires once the
|
||||||
|
// query settles, and it carries the query LENGTH only, never the text.
|
||||||
|
_searchTelemetry.QueueSearchResults(newSearch.Length, deterministicResultCount, stopwatch.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
|
||||||
|
// takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
|
||||||
|
var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
|
||||||
|
if (adjustedInterval < TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
adjustedInterval = TimeSpan.Zero;
|
||||||
|
}
|
||||||
|
|
||||||
|
RequestRefresh(fullRefresh: true, adjustedInterval);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RequestRefresh(fullRefresh: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Materializes a source into a stable, indexable snapshot so scoring can run off the lock.
|
||||||
|
// Anything already an IReadOnlyList passes through; lazy LINQ over live data gets copied.
|
||||||
|
private static IReadOnlyList<IListItem> MaterializeSource(IEnumerable<IListItem> items)
|
||||||
|
=> items as IReadOnlyList<IListItem> ?? items.ToArray();
|
||||||
|
|
||||||
private bool ActuallyLoading()
|
private bool ActuallyLoading()
|
||||||
{
|
{
|
||||||
var allApps = AllAppsCommandProvider.Page;
|
var allApps = AllAppsCommandProvider.Page;
|
||||||
@@ -640,7 +843,9 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
in FuzzyQuery query,
|
in FuzzyQuery query,
|
||||||
IListItem topLevelOrAppItem,
|
IListItem topLevelOrAppItem,
|
||||||
IRecentCommandsManager history,
|
IRecentCommandsManager history,
|
||||||
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
|
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher,
|
||||||
|
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null,
|
||||||
|
DateTimeOffset? now = null)
|
||||||
{
|
{
|
||||||
var title = topLevelOrAppItem.Title;
|
var title = topLevelOrAppItem.Title;
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
if (string.IsNullOrWhiteSpace(title))
|
||||||
@@ -705,14 +910,19 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var frecencyWeight = history.GetCommandHistoryWeight(id);
|
var frecencyWeight = history.GetCommandHistoryWeight(id, now ?? DateTimeOffset.UtcNow);
|
||||||
var aliasSubstringBonus = isAliasSubstringMatch && !isAliasMatch ? MainListRanker.AliasSubstringBonus : 0.0;
|
var aliasSubstringBonus = isAliasSubstringMatch && !isAliasMatch ? MainListRanker.AliasSubstringBonus : 0.0;
|
||||||
|
|
||||||
|
// Per-provider weight is a within-tier nudge only. Resolving it here (rather than in
|
||||||
|
// the tier classifier) guarantees it can never promote an item across a tier boundary.
|
||||||
|
var providerWeight = providerWeightLookup?.Invoke(topLevelOrAppItem) ?? ProviderSearchWeight.Normal;
|
||||||
|
var providerBonus = MainListRanker.ProviderBonus(providerWeight);
|
||||||
|
|
||||||
var withinTier = MainListRanker.WithinTierScore(
|
var withinTier = MainListRanker.WithinTierScore(
|
||||||
lexicalQuality,
|
lexicalQuality,
|
||||||
frecencyWeight,
|
frecencyWeight,
|
||||||
aliasSubstringBonus,
|
aliasSubstringBonus,
|
||||||
providerBonus: 0.0);
|
providerBonus: providerBonus);
|
||||||
|
|
||||||
return MainListRanker.Pack(tier, withinTier);
|
return MainListRanker.Pack(tier, withinTier);
|
||||||
}
|
}
|
||||||
@@ -752,6 +962,8 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
{
|
{
|
||||||
RecentCommands = state.RecentCommands.WithHistoryItem(id),
|
RecentCommands = state.RecentCommands.WithHistoryItem(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_searchTelemetry.ReportSelection(topLevelOrAppItem, _resultsSeparator, _fallbacksSeparator);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
|
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
|
||||||
@@ -767,6 +979,34 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolves the user-configured per-provider search weight for an item. Top-level commands
|
||||||
|
// carry their own provider id; installed apps all belong to the well-known "AllApps"
|
||||||
|
// provider, so app items are weighted by that provider's setting. The static overloads take a
|
||||||
|
// settings snapshot so the hot path resolves against one captured SettingsModel.
|
||||||
|
private ProviderSearchWeight ResolveProviderSearchWeight(IListItem topLevelOrAppItem)
|
||||||
|
=> ResolveProviderSearchWeight(_settingsService.Settings, topLevelOrAppItem);
|
||||||
|
|
||||||
|
private static ProviderSearchWeight ResolveProviderSearchWeight(SettingsModel settings, IListItem topLevelOrAppItem)
|
||||||
|
{
|
||||||
|
var providerId = topLevelOrAppItem is TopLevelViewModel topLevel
|
||||||
|
? topLevel.CommandProviderId
|
||||||
|
: AllAppsCommandProvider.WellKnownId;
|
||||||
|
|
||||||
|
return ResolveProviderSearchWeight(settings, providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProviderSearchWeight ResolveProviderSearchWeight(SettingsModel settings, string providerId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(providerId))
|
||||||
|
{
|
||||||
|
return ProviderSearchWeight.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings.ProviderSettings.TryGetValue(providerId, out var providerSettings)
|
||||||
|
? providerSettings.SearchWeight
|
||||||
|
: ProviderSearchWeight.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
|
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
|
||||||
|
|
||||||
public void Receive(UpdateFallbackItemsMessage message)
|
public void Receive(UpdateFallbackItemsMessage message)
|
||||||
@@ -778,13 +1018,72 @@ public sealed partial class MainListPage : DynamicListPage,
|
|||||||
|
|
||||||
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) => HotReloadSettings(args);
|
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args) => HotReloadSettings(args);
|
||||||
|
|
||||||
private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
|
private void HotReloadSettings(SettingsModel settings)
|
||||||
|
{
|
||||||
|
ShowDetails = settings.ShowAppDetails;
|
||||||
|
|
||||||
|
// A per-provider search-weight change has to reorder the query that is already on screen.
|
||||||
|
// Scoring reads the weight live, but scored results are cached, so without an explicit
|
||||||
|
// re-score the active query keeps its old order until the next keystroke. Detect a weight
|
||||||
|
// change and re-rank the current search in place.
|
||||||
|
var providerSettings = settings.ProviderSettings;
|
||||||
|
var weightsChanged = ProviderWeightsChanged(_lastProviderSettingsSnapshot, providerSettings);
|
||||||
|
_lastProviderSettingsSnapshot = providerSettings;
|
||||||
|
|
||||||
|
if (weightsChanged && !string.IsNullOrEmpty(SearchText))
|
||||||
|
{
|
||||||
|
RerankActiveSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-scores the current query off the UI thread so a settings change (e.g. a per-provider
|
||||||
|
// search-weight change) reorders the results already shown. This reuses the same non-reset
|
||||||
|
// re-score path as an app-inclusion refresh: the retained matches are re-scored with the new
|
||||||
|
// weights, which is sufficient because provider weight only nudges order within a tier and
|
||||||
|
// never changes which items match.
|
||||||
|
private void RerankActiveSearch()
|
||||||
|
{
|
||||||
|
var current = SearchText;
|
||||||
|
if (!string.IsNullOrEmpty(current))
|
||||||
|
{
|
||||||
|
_ = Task.Run(() => UpdateSearchTextCore(current, current, isUserInput: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the effective per-provider search weight differs between two snapshots. A provider
|
||||||
|
// absent from a snapshot is treated as Normal, so adding or removing an entry whose weight is
|
||||||
|
// Normal does not count as a change.
|
||||||
|
private static bool ProviderWeightsChanged(
|
||||||
|
ImmutableDictionary<string, ProviderSettings>? previous,
|
||||||
|
ImmutableDictionary<string, ProviderSettings> current)
|
||||||
|
{
|
||||||
|
previous ??= ImmutableDictionary<string, ProviderSettings>.Empty;
|
||||||
|
if (ReferenceEquals(previous, current))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = new HashSet<string>(previous.Keys, StringComparer.Ordinal);
|
||||||
|
keys.UnionWith(current.Keys);
|
||||||
|
foreach (var key in keys)
|
||||||
|
{
|
||||||
|
var previousWeight = previous.TryGetValue(key, out var p) ? p.SearchWeight : ProviderSearchWeight.Normal;
|
||||||
|
var currentWeight = current.TryGetValue(key, out var c) ? c.SearchWeight : ProviderSearchWeight.Normal;
|
||||||
|
if (previousWeight != currentWeight)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_cancellationTokenSource?.Cancel();
|
_cancellationTokenSource?.Cancel();
|
||||||
_cancellationTokenSource?.Dispose();
|
_cancellationTokenSource?.Dispose();
|
||||||
_fallbackUpdateManager.Dispose();
|
_fallbackUpdateManager.Dispose();
|
||||||
|
_searchTelemetry.Dispose();
|
||||||
|
|
||||||
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
|
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
|
||||||
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
|
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// 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 System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using Microsoft.CmdPal.Common.Helpers;
|
||||||
|
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns all of the main-page search telemetry state and emission, so <see cref="MainListPage"/>
|
||||||
|
/// stays responsible for producing results and this type is the single, clearly-identifiable owner
|
||||||
|
/// of the (privacy-safe) telemetry. Everything here is opt-in and non-identifying: search events
|
||||||
|
/// carry only query LENGTH, result count and latency; selection events carry only query LENGTH, the
|
||||||
|
/// invoked item's visible rank and its ranker tier - never the raw query text or item content.
|
||||||
|
///
|
||||||
|
/// Search events are emitted only when a query settles (trailing-edge debounce) so we never send an
|
||||||
|
/// event on every keystroke; selection events are emitted only when the user invokes a result. All
|
||||||
|
/// emission is measured at boundaries, never inside the per-item scoring loop.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class MainListPageSearchTelemetry : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(600);
|
||||||
|
|
||||||
|
private readonly ThrottledDebouncedAction _resultsDebounce;
|
||||||
|
private readonly Lock _pendingLock = new();
|
||||||
|
private (int QueryLength, int ResultCount, long LatencyMs) _pendingResults;
|
||||||
|
|
||||||
|
// Snapshots of the most recent rendered search results, read off the hot path (only when the
|
||||||
|
// user invokes a result) to resolve the invoked item's visible rank and ranker tier. The scored
|
||||||
|
// inputs and query length are captured together with the rendered items at render time, so
|
||||||
|
// selection telemetry resolves rank, tier and query length from one coherent generation even
|
||||||
|
// after a newer query has already published fresh scored fields.
|
||||||
|
private IReadOnlyList<IListItem>? _lastViewItems;
|
||||||
|
private IReadOnlyList<RoScored<IListItem>>? _lastScoredGlobalFallbacks;
|
||||||
|
private RoScored<IListItem>[]? _lastViewFilteredItems;
|
||||||
|
private RoScored<IListItem>[]? _lastViewFilteredApps;
|
||||||
|
private IEnumerable<RoScored<IListItem>>? _lastViewFallbackItems;
|
||||||
|
private int _lastViewQueryLength;
|
||||||
|
|
||||||
|
public MainListPageSearchTelemetry()
|
||||||
|
{
|
||||||
|
_resultsDebounce = new ThrottledDebouncedAction(EmitPendingResults, SettleDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores the latest settled-search metrics and (re)arms the debounce. Only the query LENGTH is
|
||||||
|
// retained - the query text is never stored for telemetry.
|
||||||
|
public void QueueSearchResults(int queryLength, int resultCount, long latencyMs)
|
||||||
|
{
|
||||||
|
lock (_pendingLock)
|
||||||
|
{
|
||||||
|
_pendingResults = (queryLength, resultCount, latencyMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultsDebounce.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshots the rendered order plus every scored input and the query length together, so a later
|
||||||
|
// selection resolves an invoked item's rank, tier and query length from this one generation off
|
||||||
|
// the hot path. These are plain reference assignments - no extra allocation.
|
||||||
|
public void CaptureSearchView(
|
||||||
|
IReadOnlyList<IListItem> renderedItems,
|
||||||
|
RoScored<IListItem>[]? filteredItems,
|
||||||
|
RoScored<IListItem>[]? filteredApps,
|
||||||
|
IReadOnlyList<RoScored<IListItem>>? scoredGlobalFallbacks,
|
||||||
|
IEnumerable<RoScored<IListItem>>? fallbackItems,
|
||||||
|
int queryLength)
|
||||||
|
{
|
||||||
|
_lastViewItems = renderedItems;
|
||||||
|
_lastScoredGlobalFallbacks = scoredGlobalFallbacks;
|
||||||
|
_lastViewFilteredItems = filteredItems;
|
||||||
|
_lastViewFilteredApps = filteredApps;
|
||||||
|
_lastViewFallbackItems = fallbackItems;
|
||||||
|
_lastViewQueryLength = queryLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops any pending settled-search event without emitting it. Used when an alias query supersedes
|
||||||
|
// a normal query whose telemetry is still pending in the debounce.
|
||||||
|
public void CancelPendingResults() => _resultsDebounce.Cancel();
|
||||||
|
|
||||||
|
// Drops any pending settled-search event and forgets the last rendered search view, so a cleared
|
||||||
|
// query never emits and a subsequent selection resolves to nothing.
|
||||||
|
public void ClearSearchView()
|
||||||
|
{
|
||||||
|
_resultsDebounce.Cancel();
|
||||||
|
_lastViewItems = null;
|
||||||
|
_lastScoredGlobalFallbacks = null;
|
||||||
|
_lastViewFilteredItems = null;
|
||||||
|
_lastViewFilteredApps = null;
|
||||||
|
_lastViewFallbackItems = null;
|
||||||
|
_lastViewQueryLength = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emits selection telemetry when the user invokes a result during an active search. Runs only on
|
||||||
|
// invoke (a deliberate, infrequent user action - never on the typing/scoring path) and captures
|
||||||
|
// only non-identifying aggregates: the query LENGTH, the invoked item's visible rank, and its
|
||||||
|
// ranker tier. Nothing is emitted for the default (no-search) view, or when the invoked item is
|
||||||
|
// not among the last rendered search results.
|
||||||
|
public void ReportSelection(IListItem invoked, Separator resultsSeparator, Separator fallbacksSeparator)
|
||||||
|
{
|
||||||
|
// Resolve everything from the last rendered search-view snapshot so the invoked item's rank,
|
||||||
|
// tier, and the reported query length all come from one generation. If the last render was
|
||||||
|
// the default (no-search) view, _lastViewItems is null and nothing is emitted.
|
||||||
|
var lastView = _lastViewItems;
|
||||||
|
if (lastView is null || _lastViewQueryLength <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = ResolveVisibleIndex(lastView, invoked, resultsSeparator, fallbacksSeparator);
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var packed = (_lastViewFilteredItems ?? Enumerable.Empty<RoScored<IListItem>>())
|
||||||
|
.Concat(_lastViewFilteredApps ?? Enumerable.Empty<RoScored<IListItem>>())
|
||||||
|
.Concat(_lastScoredGlobalFallbacks ?? Enumerable.Empty<RoScored<IListItem>>());
|
||||||
|
|
||||||
|
var tier = ResolveSelectedTier(invoked, packed, _lastViewFallbackItems);
|
||||||
|
if (tier == RankTier.None)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Send(BuildSearchSelectedMessage(_lastViewQueryLength, index, tier));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EmitPendingResults()
|
||||||
|
{
|
||||||
|
(int QueryLength, int ResultCount, long LatencyMs) snapshot;
|
||||||
|
lock (_pendingLock)
|
||||||
|
{
|
||||||
|
snapshot = _pendingResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.QueryLength <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Send(
|
||||||
|
BuildSearchResultsMessage(snapshot.QueryLength, snapshot.ResultCount, snapshot.LatencyMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the settled-search telemetry payload from a query string, capturing only its LENGTH.
|
||||||
|
// Exposed for tests to prove the raw query text is never carried.
|
||||||
|
internal static TelemetrySearchResultsMessage BuildSearchResultsMessage(string query, int resultCount, long latencyMs)
|
||||||
|
=> BuildSearchResultsMessage(query?.Length ?? 0, resultCount, latencyMs);
|
||||||
|
|
||||||
|
internal static TelemetrySearchResultsMessage BuildSearchResultsMessage(int queryLength, int resultCount, long latencyMs)
|
||||||
|
{
|
||||||
|
var length = Math.Max(queryLength, 0);
|
||||||
|
var count = Math.Max(resultCount, 0);
|
||||||
|
var latency = latencyMs < 0 ? 0UL : (ulong)latencyMs;
|
||||||
|
return new TelemetrySearchResultsMessage(length, count, count == 0, latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the selection telemetry payload, capturing only the query LENGTH, the selected rank,
|
||||||
|
// and the ranker tier. Exposed for tests to prove the raw query text is never carried.
|
||||||
|
internal static TelemetrySearchResultSelectedMessage BuildSearchSelectedMessage(string query, int selectedIndex, RankTier selectedTier)
|
||||||
|
=> BuildSearchSelectedMessage(query?.Length ?? 0, selectedIndex, selectedTier);
|
||||||
|
|
||||||
|
internal static TelemetrySearchResultSelectedMessage BuildSearchSelectedMessage(int queryLength, int selectedIndex, RankTier selectedTier)
|
||||||
|
=> new(Math.Max(queryLength, 0), selectedIndex, selectedTier);
|
||||||
|
|
||||||
|
// Zero-based visible rank of an invoked item within the rendered results, skipping the section
|
||||||
|
// separators. Returns -1 when the item is not present (e.g. it was invoked from a different view).
|
||||||
|
internal static int ResolveVisibleIndex(IReadOnlyList<IListItem>? renderedResults, IListItem invoked, params IListItem[] separators)
|
||||||
|
{
|
||||||
|
if (renderedResults is null)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var visible = 0;
|
||||||
|
foreach (var item in renderedResults)
|
||||||
|
{
|
||||||
|
var isSeparator = false;
|
||||||
|
foreach (var separator in separators)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(item, separator))
|
||||||
|
{
|
||||||
|
isSeparator = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSeparator)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReferenceEquals(item, invoked))
|
||||||
|
{
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves the ranker tier of an invoked item. Packed sources (commands, apps, global
|
||||||
|
// fallbacks) decode their tier via MainListRanker.TierOf; common fallbacks carry rank-based
|
||||||
|
// (non-packed) scores, so they are reported at the fallback floor. Returns None when the item
|
||||||
|
// is not found in any source.
|
||||||
|
internal static RankTier ResolveSelectedTier(
|
||||||
|
IListItem invoked,
|
||||||
|
IEnumerable<RoScored<IListItem>>? packedResults,
|
||||||
|
IEnumerable<RoScored<IListItem>>? fallbackResults)
|
||||||
|
{
|
||||||
|
if (packedResults is not null)
|
||||||
|
{
|
||||||
|
foreach (var scored in packedResults)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(scored.Item, invoked))
|
||||||
|
{
|
||||||
|
return MainListRanker.TierOf(scored.Score);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackResults is not null)
|
||||||
|
{
|
||||||
|
foreach (var scored in fallbackResults)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(scored.Item, invoked))
|
||||||
|
{
|
||||||
|
return RankTier.FallbackFloor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RankTier.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _resultsDebounce.Dispose();
|
||||||
|
}
|
||||||
@@ -31,6 +31,23 @@ internal static class MainListRanker
|
|||||||
// exact alias, which gets its own top tier). Mirrors the previous +1-before-x10 boost.
|
// exact alias, which gets its own top tier). Mirrors the previous +1-before-x10 boost.
|
||||||
internal const double AliasSubstringBonus = 10.0;
|
internal const double AliasSubstringBonus = 10.0;
|
||||||
|
|
||||||
|
// Magnitude of the per-provider within-tier nudge. Deliberately small - half a point of
|
||||||
|
// lexical quality (LexicalScale = 10) - so a Higher/Lower provider only breaks near-ties
|
||||||
|
// and reorders items that already share a tier. It can NEVER move an item across a tier
|
||||||
|
// boundary because the packed within-tier score is clamped to a single tier's band.
|
||||||
|
internal const double ProviderWeightBonus = 5.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a per-provider <see cref="ProviderSearchWeight"/> to an additive within-tier
|
||||||
|
/// bonus. Lower subtracts, Normal is neutral, Higher adds. The enum's underlying value is
|
||||||
|
/// the sign of the nudge, so the result is simply the weight times
|
||||||
|
/// <see cref="ProviderWeightBonus"/>. Note the nudge is slightly asymmetric at the tier
|
||||||
|
/// floor: <see cref="Pack"/> clamps the within-tier score to a non-negative band, so a
|
||||||
|
/// Lower nudge on an item already scoring near 0 (weak match, no history) can clamp to 0
|
||||||
|
/// and read the same as Normal, whereas Higher always applies.
|
||||||
|
/// </summary>
|
||||||
|
public static double ProviderBonus(ProviderSearchWeight weight) => (int)weight * ProviderWeightBonus;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Packs a tier and within-tier score into a single descending-sortable integer.
|
/// Packs a tier and within-tier score into a single descending-sortable integer.
|
||||||
/// Returns 0 for <see cref="RankTier.None"/> so non-matches are filtered by the
|
/// Returns 0 for <see cref="RankTier.None"/> so non-matches are filtered by the
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using System;
|
||||||
|
|
||||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||||
|
|
||||||
@@ -11,4 +11,13 @@ public record HistoryItem
|
|||||||
public required string CommandId { get; init; }
|
public required string CommandId { get; init; }
|
||||||
|
|
||||||
public required int Uses { get; init; }
|
public required int Uses { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the moment this command was last invoked. Persisted so ranking can apply real
|
||||||
|
/// time-decay instead of the previous list-position heuristic. History written before
|
||||||
|
/// this field existed deserializes to <c>default(DateTimeOffset)</c>;
|
||||||
|
/// <see cref="RecentCommandsManager"/> treats that sentinel as a mild backdate so
|
||||||
|
/// day-one ordering degrades gracefully to Uses-ordering rather than going all-equal.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset LastUsed { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// 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 Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telemetry message sent when the user selects (invokes) a main-page search result.
|
||||||
|
/// Carries only non-identifying aggregates - never the selected item's title/id or the raw
|
||||||
|
/// query text. <see cref="QueryLength"/> is the query's character count, <see cref="SelectedIndex"/>
|
||||||
|
/// is the zero-based rank of the selected result, and <see cref="SelectedTier"/> is its ranker tier.
|
||||||
|
/// </summary>
|
||||||
|
public record TelemetrySearchResultSelectedMessage(int QueryLength, int SelectedIndex, RankTier SelectedTier);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telemetry message sent when a main-page search query settles.
|
||||||
|
/// Carries only non-identifying aggregates - never the raw query text. <see cref="QueryLength"/>
|
||||||
|
/// is the query's character count.
|
||||||
|
/// </summary>
|
||||||
|
public record TelemetrySearchResultsMessage(int QueryLength, int ResultCount, bool NoResults, ulong LatencyMs);
|
||||||
@@ -7,6 +7,25 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-provider relevance nudge applied to main/root page search results. The value is a
|
||||||
|
/// within-tier bonus only: it can reorder items that already share a relevance tier but can
|
||||||
|
/// never move an item across a tier boundary. The underlying integer values are the sign of
|
||||||
|
/// the bonus (Lower subtracts, Higher adds), and <see cref="Normal"/> is 0 so a missing or
|
||||||
|
/// legacy setting deserializes to the neutral default.
|
||||||
|
/// </summary>
|
||||||
|
public enum ProviderSearchWeight
|
||||||
|
{
|
||||||
|
/// <summary>De-prioritize this provider's results within their tier.</summary>
|
||||||
|
Lower = -1,
|
||||||
|
|
||||||
|
/// <summary>Default. No provider nudge.</summary>
|
||||||
|
Normal = 0,
|
||||||
|
|
||||||
|
/// <summary>Prioritize this provider's results within their tier.</summary>
|
||||||
|
Higher = 1,
|
||||||
|
}
|
||||||
|
|
||||||
public record ProviderSettings
|
public record ProviderSettings
|
||||||
{
|
{
|
||||||
// List of built-in fallbacks that should not have global results enabled by default
|
// List of built-in fallbacks that should not have global results enabled by default
|
||||||
@@ -18,6 +37,12 @@ public record ProviderSettings
|
|||||||
|
|
||||||
public bool IsEnabled { get; init; } = true;
|
public bool IsEnabled { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-provider within-tier ranking nudge for main-page search. Defaults to
|
||||||
|
/// <see cref="ProviderSearchWeight.Normal"/>; missing/legacy values deserialize to Normal.
|
||||||
|
/// </summary>
|
||||||
|
public ProviderSearchWeight SearchWeight { get; init; } = ProviderSearchWeight.Normal;
|
||||||
|
|
||||||
private ImmutableDictionary<string, FallbackSettings>? _fallbackCommands
|
private ImmutableDictionary<string, FallbackSettings>? _fallbackCommands
|
||||||
= ImmutableDictionary<string, FallbackSettings>.Empty;
|
= ImmutableDictionary<string, FallbackSettings>.Empty;
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,41 @@ public partial class ProviderSettingsViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-provider search weight surfaced as a 0/1/2 ComboBox index (Lower / Normal /
|
||||||
|
/// Higher) so it can bind to <c>ComboBox.SelectedIndex</c> in the same style as the
|
||||||
|
/// other per-provider options. Persists to <see cref="ProviderSettings.SearchWeight"/>.
|
||||||
|
/// </summary>
|
||||||
|
public int SearchWeightIndex
|
||||||
|
{
|
||||||
|
get => _providerSettings.SearchWeight switch
|
||||||
|
{
|
||||||
|
ProviderSearchWeight.Lower => 0,
|
||||||
|
ProviderSearchWeight.Higher => 2,
|
||||||
|
_ => 1,
|
||||||
|
};
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var newWeight = value switch
|
||||||
|
{
|
||||||
|
0 => ProviderSearchWeight.Lower,
|
||||||
|
2 => ProviderSearchWeight.Higher,
|
||||||
|
_ => ProviderSearchWeight.Normal,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (newWeight != _providerSettings.SearchWeight)
|
||||||
|
{
|
||||||
|
var newSettings = _providerSettings with { SearchWeight = newWeight };
|
||||||
|
_settingsService.UpdateSettings(s => s with
|
||||||
|
{
|
||||||
|
ProviderSettings = s.ProviderSettings.SetItem(_provider.ProviderId, newSettings),
|
||||||
|
});
|
||||||
|
_providerSettings = newSettings;
|
||||||
|
OnPropertyChanged(nameof(SearchWeightIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a value indicating whether returns true if we have a settings page
|
/// Gets a value indicating whether returns true if we have a settings page
|
||||||
/// that's initialized, or we are still working on initializing that
|
/// that's initialized, or we are still working on initializing that
|
||||||
|
|||||||
@@ -3,61 +3,156 @@
|
|||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||||
|
|
||||||
public record RecentCommandsManager : IRecentCommandsManager
|
public record RecentCommandsManager : IRecentCommandsManager
|
||||||
{
|
{
|
||||||
|
// Recency half-life: a command's recency contribution halves every this many days. Three
|
||||||
|
// days balances an interactive launcher's usage rhythm - commands touched in the last few
|
||||||
|
// days still feel "recent", while month-old one-offs decay toward zero and stop crowding
|
||||||
|
// out newer habits.
|
||||||
|
internal const double HalfLifeDays = 3.0;
|
||||||
|
|
||||||
|
// Baseline points for a just-used command (recency == 1) before the frequency term. Kept
|
||||||
|
// near the previous top recency bucket so the frecency signal keeps roughly the same
|
||||||
|
// influence in the ranker's within-tier math (MainListRanker.FrecencyScale == 1.0).
|
||||||
|
internal const double BaseWeight = 10.0;
|
||||||
|
|
||||||
|
// Points added per unit of log2(uses + 1), scaled by recency. log() keeps heavy usage
|
||||||
|
// helpful without letting it dominate recency (uses 1 -> +10, 7 -> +30, 31 -> +50).
|
||||||
|
internal const double FrequencyWeight = 10.0;
|
||||||
|
|
||||||
|
// Upper bound on the returned weight. Holds the signal in the ~0..70 range the previous
|
||||||
|
// implementation produced, so tier ordering is unaffected (the tier always dominates and
|
||||||
|
// frecency only reorders within a tier).
|
||||||
|
internal const double MaxWeight = 70.0;
|
||||||
|
|
||||||
|
// Retain a few hundred commands. Large enough to remember habitual commands across weeks
|
||||||
|
// of use, small enough that the persisted JSON stays tiny (tens of KB). The old cap of 50
|
||||||
|
// evicted still-useful history within a single busy session on active machines.
|
||||||
|
internal const int MaxHistoryEntries = 500;
|
||||||
|
|
||||||
|
// Defensive fallback for any history item that carries a default (missing) LastUsed - for
|
||||||
|
// example a value constructed without a timestamp, or state written by a build that predates
|
||||||
|
// the LastUsed field. Such an item is treated as used one day ago: recent enough that it
|
||||||
|
// still ranks, mild enough that a single fresh use outranks it, and uniform so a group of
|
||||||
|
// them falls back to Uses (frequency) ordering instead of collapsing to all-equal or zero.
|
||||||
|
// In practice this rarely fires: earlier builds never actually persisted history (the
|
||||||
|
// internal History property was dropped by the serializer, see [JsonInclude] below), so
|
||||||
|
// upgrading users start from an empty store rather than one full of timestamp-less items.
|
||||||
|
internal static readonly TimeSpan LegacyBackdate = TimeSpan.FromDays(1);
|
||||||
|
|
||||||
private ImmutableList<HistoryItem>? _history = ImmutableList<HistoryItem>.Empty;
|
private ImmutableList<HistoryItem>? _history = ImmutableList<HistoryItem>.Empty;
|
||||||
|
|
||||||
|
// Cached commandId -> entry lookup over History, rebuilt lazily whenever History is
|
||||||
|
// (re)assigned and never persisted. ScoreTopLevelItem calls GetCommandHistoryWeight for
|
||||||
|
// every candidate item on every keystroke, and History can hold up to MaxHistoryEntries
|
||||||
|
// (500) entries, so a plain linear scan would be O(items x history) per keystroke. The
|
||||||
|
// dictionary keeps each lookup O(1) so the hot path stays cheap as the store grows.
|
||||||
|
[JsonIgnore]
|
||||||
|
private Dictionary<string, HistoryItem>? _index;
|
||||||
|
|
||||||
|
// Persisted so recent-command frecency (including the LastUsed timestamps) survives a
|
||||||
|
// restart. [JsonInclude] is required because the property is internal; without it the
|
||||||
|
// source-generated serializer emits an empty object and history is silently dropped.
|
||||||
|
// Old persisted state (an empty object) still deserializes fine - History stays empty.
|
||||||
|
[JsonInclude]
|
||||||
internal ImmutableList<HistoryItem> History
|
internal ImmutableList<HistoryItem> History
|
||||||
{
|
{
|
||||||
get => _history ?? ImmutableList<HistoryItem>.Empty;
|
get => _history ?? ImmutableList<HistoryItem>.Empty;
|
||||||
init => _history = value;
|
init
|
||||||
|
{
|
||||||
|
_history = value;
|
||||||
|
|
||||||
|
// Invalidate the cached lookup so it is rebuilt from the new list on next use.
|
||||||
|
// A record 'with' copy carries over the old field, so this reset is what keeps
|
||||||
|
// the index from going stale after History changes.
|
||||||
|
_index = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<string, HistoryItem> Index
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_index is null)
|
||||||
|
{
|
||||||
|
// Ordinal to match the string '==' comparison the previous linear scan used.
|
||||||
|
var map = new Dictionary<string, HistoryItem>(StringComparer.Ordinal);
|
||||||
|
foreach (var item in History)
|
||||||
|
{
|
||||||
|
// History is most-recent-first and command ids are unique in it, but keep
|
||||||
|
// the first (most recent) occurrence if a duplicate ever slips in so the
|
||||||
|
// lookup matches the old FirstOrDefault behavior.
|
||||||
|
map.TryAdd(item.CommandId, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
_index = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _index;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public RecentCommandsManager()
|
public RecentCommandsManager()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the lazy command-id lookup now, on the calling thread. The build isn't thread-safe,
|
||||||
|
/// so call this once before scoring items in parallel; the reads afterward are safe.
|
||||||
|
/// </summary>
|
||||||
|
public void PrewarmIndex() => _ = Index;
|
||||||
|
|
||||||
public int GetCommandHistoryWeight(string commandId)
|
public int GetCommandHistoryWeight(string commandId)
|
||||||
|
=> GetCommandHistoryWeight(commandId, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the time-decayed frecency weight for a command relative to <paramref name="now"/>.
|
||||||
|
/// Recency uses an exponential half-life decay and frequency uses log(uses); the two are
|
||||||
|
/// combined so recency leads while frequency amplifies. The parameterless overload uses the
|
||||||
|
/// current time; this one lets a batch pin a single snapshot.
|
||||||
|
/// </summary>
|
||||||
|
public int GetCommandHistoryWeight(string commandId, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
var entry = History
|
if (!Index.TryGetValue(commandId, out var entry))
|
||||||
.Index()
|
|
||||||
.Where(item => item.Item.CommandId == commandId)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
// These numbers are vaguely scaled so that "VS" will make "Visual Studio" the
|
|
||||||
// match after one use.
|
|
||||||
// Usually it has a weight of 84, compared to 109 for the VS cmd prompt
|
|
||||||
if (entry.Item is not null)
|
|
||||||
{
|
{
|
||||||
var index = entry.Index;
|
return 0;
|
||||||
|
|
||||||
// First, add some weight based on how early in the list this appears
|
|
||||||
var bucket = index switch
|
|
||||||
{
|
|
||||||
_ when index <= 2 => 35,
|
|
||||||
_ when index <= 10 => 25,
|
|
||||||
_ when index <= 15 => 15,
|
|
||||||
_ when index <= 35 => 10,
|
|
||||||
_ => 5,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Then, add weight for how often this is used, but cap the weight from usage.
|
|
||||||
var uses = Math.Min(entry.Item.Uses * 5, 35);
|
|
||||||
|
|
||||||
return bucket + uses;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
// Migrate items with a default (missing) timestamp to a mild backdate so they degrade
|
||||||
|
// to Uses-ordering instead of appearing brand-new or invisible. See LegacyBackdate.
|
||||||
|
var lastUsed = entry.LastUsed == default ? now - LegacyBackdate : entry.LastUsed;
|
||||||
|
|
||||||
|
// Clamp age at zero so a slightly-future timestamp (e.g. clock skew) can't amplify.
|
||||||
|
var ageDays = Math.Max(0.0, (now - lastUsed).TotalDays);
|
||||||
|
|
||||||
|
// Exponential time decay: recency in (0, 1], halving every HalfLifeDays.
|
||||||
|
var recency = Math.Pow(2.0, -ageDays / HalfLifeDays);
|
||||||
|
|
||||||
|
// Frequency via log2 so heavy usage helps but can't outrun recency.
|
||||||
|
var frequency = Math.Log(entry.Uses + 1, 2);
|
||||||
|
|
||||||
|
var weight = recency * (BaseWeight + (FrequencyWeight * frequency));
|
||||||
|
|
||||||
|
return (int)Math.Round(Math.Clamp(weight, 0.0, MaxWeight));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a new RecentCommandsManager with the given command added/promoted in history.
|
/// Returns a new RecentCommandsManager with the given command added/promoted in history.
|
||||||
/// Pure function — does not mutate this instance.
|
/// Pure function - does not mutate this instance.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public RecentCommandsManager WithHistoryItem(string commandId)
|
public RecentCommandsManager WithHistoryItem(string commandId)
|
||||||
|
=> WithHistoryItem(commandId, DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a use of <paramref name="commandId"/> at the explicit time <paramref name="now"/>.
|
||||||
|
/// The public overload uses the current time; this overload lets tests inject
|
||||||
|
/// strictly-increasing timestamps so time-decay behavior is deterministic.
|
||||||
|
/// </summary>
|
||||||
|
internal RecentCommandsManager WithHistoryItem(string commandId, DateTimeOffset now)
|
||||||
{
|
{
|
||||||
var existing = History.FirstOrDefault(item => item.CommandId == commandId);
|
var existing = History.FirstOrDefault(item => item.CommandId == commandId);
|
||||||
ImmutableList<HistoryItem> newHistory;
|
ImmutableList<HistoryItem> newHistory;
|
||||||
@@ -65,18 +160,18 @@ public record RecentCommandsManager : IRecentCommandsManager
|
|||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
{
|
{
|
||||||
newHistory = History.Remove(existing);
|
newHistory = History.Remove(existing);
|
||||||
var updated = existing with { Uses = existing.Uses + 1 };
|
var updated = existing with { Uses = existing.Uses + 1, LastUsed = now };
|
||||||
newHistory = newHistory.Insert(0, updated);
|
newHistory = newHistory.Insert(0, updated);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var newItem = new HistoryItem { CommandId = commandId, Uses = 1 };
|
var newItem = new HistoryItem { CommandId = commandId, Uses = 1, LastUsed = now };
|
||||||
newHistory = History.Insert(0, newItem);
|
newHistory = History.Insert(0, newItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newHistory.Count > 50)
|
if (newHistory.Count > MaxHistoryEntries)
|
||||||
{
|
{
|
||||||
newHistory = newHistory.RemoveRange(50, newHistory.Count - 50);
|
newHistory = newHistory.RemoveRange(MaxHistoryEntries, newHistory.Count - MaxHistoryEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this with { History = newHistory };
|
return this with { History = newHistory };
|
||||||
@@ -87,5 +182,17 @@ public interface IRecentCommandsManager
|
|||||||
{
|
{
|
||||||
int GetCommandHistoryWeight(string commandId);
|
int GetCommandHistoryWeight(string commandId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frecency weight for a command at an explicit evaluation time, so a whole batch can be
|
||||||
|
/// scored against one snapshot instead of a moving clock.
|
||||||
|
/// </summary>
|
||||||
|
int GetCommandHistoryWeight(string commandId, DateTimeOffset now);
|
||||||
|
|
||||||
RecentCommandsManager WithHistoryItem(string commandId);
|
RecentCommandsManager WithHistoryItem(string commandId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds any lazy internal state on the calling thread. Call it once before scoring items in
|
||||||
|
/// parallel, since the build itself isn't thread-safe.
|
||||||
|
/// </summary>
|
||||||
|
void PrewarmIndex();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -433,12 +433,16 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
|
|||||||
// RPC to check type
|
// RPC to check type
|
||||||
if (model is IFallbackCommandItem fallback)
|
if (model is IFallbackCommandItem fallback)
|
||||||
{
|
{
|
||||||
var wasEmpty = string.IsNullOrEmpty(Title);
|
var oldTitle = Title;
|
||||||
|
|
||||||
// RPC for method
|
// RPC for method
|
||||||
fallback.FallbackHandler.UpdateQuery(newQuery);
|
fallback.FallbackHandler.UpdateQuery(newQuery);
|
||||||
var isEmpty = string.IsNullOrEmpty(Title);
|
var newTitle = Title;
|
||||||
return wasEmpty != isEmpty;
|
|
||||||
|
// Report any title change, not just an empty <-> non-empty flip: the render path
|
||||||
|
// re-scores fallbacks off this signal, so a change like "server01" -> "server02"
|
||||||
|
// must still trigger a refresh or the fallback keeps its stale score and position.
|
||||||
|
return !string.Equals(oldTitle, newTitle, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// 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.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Diagnostics.Tracing;
|
||||||
|
using Microsoft.PowerToys.Telemetry;
|
||||||
|
using Microsoft.PowerToys.Telemetry.Events;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks which main-page search result the user selected (invoked).
|
||||||
|
/// Purpose: measure whether the ranking overhaul surfaces the wanted result near the top.
|
||||||
|
/// Privacy: only non-identifying aggregates are captured. The selected item's title,
|
||||||
|
/// subtitle, id, and the raw query text are never logged - only the query character
|
||||||
|
/// length, the zero-based rank (index) of the selected result, and its ranker tier name.
|
||||||
|
/// Emission goes through <see cref="PowerToysTelemetry"/>, which respects the existing
|
||||||
|
/// PowerToys data-diagnostics consent gate.
|
||||||
|
/// </summary>
|
||||||
|
[EventData]
|
||||||
|
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||||
|
public class CmdPalSearchResultSelected : EventBase, IEvent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the character length of the query at selection (never the query text).
|
||||||
|
/// </summary>
|
||||||
|
public int QueryLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the zero-based rank of the selected result within the visible results.
|
||||||
|
/// </summary>
|
||||||
|
public int SelectedIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ranker tier name of the selected result (e.g. "ExactTitle").
|
||||||
|
/// This is a fixed, non-identifying enum name, not user content.
|
||||||
|
/// </summary>
|
||||||
|
public string SelectedTier { get; set; }
|
||||||
|
|
||||||
|
public CmdPalSearchResultSelected(int queryLength, int selectedIndex, string selectedTier)
|
||||||
|
{
|
||||||
|
EventName = "CmdPal_SearchResultSelected";
|
||||||
|
QueryLength = queryLength;
|
||||||
|
SelectedIndex = selectedIndex;
|
||||||
|
SelectedTier = selectedTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 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.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Diagnostics.Tracing;
|
||||||
|
using Microsoft.PowerToys.Telemetry;
|
||||||
|
using Microsoft.PowerToys.Telemetry.Events;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tracks the outcome of a settled main-page search query.
|
||||||
|
/// Purpose: measure search relevance and perceived speed for the ranking overhaul.
|
||||||
|
/// Privacy: only non-identifying aggregates are captured. The raw query text is never
|
||||||
|
/// logged - only its character length. No titles, subtitles, paths, or app names are
|
||||||
|
/// captured. Emission goes through <see cref="PowerToysTelemetry"/>, which respects the
|
||||||
|
/// existing PowerToys data-diagnostics consent gate.
|
||||||
|
/// </summary>
|
||||||
|
[EventData]
|
||||||
|
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
|
||||||
|
public class CmdPalSearchResults : EventBase, IEvent
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the character length of the query (never the query text itself).
|
||||||
|
/// </summary>
|
||||||
|
public int QueryLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of deterministic first-paint results (commands and apps)
|
||||||
|
/// produced for the query.
|
||||||
|
/// </summary>
|
||||||
|
public int ResultCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the query produced no deterministic results.
|
||||||
|
/// </summary>
|
||||||
|
public bool NoResults { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the time in milliseconds to produce the deterministic first-paint results.
|
||||||
|
/// </summary>
|
||||||
|
public ulong LatencyMs { get; set; }
|
||||||
|
|
||||||
|
public CmdPalSearchResults(int queryLength, int resultCount, bool noResults, ulong latencyMs)
|
||||||
|
{
|
||||||
|
EventName = "CmdPal_SearchResults";
|
||||||
|
QueryLength = queryLength;
|
||||||
|
ResultCount = resultCount;
|
||||||
|
NoResults = noResults;
|
||||||
|
LatencyMs = latencyMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ internal sealed class TelemetryForwarder :
|
|||||||
IRecipient<TelemetryBeginInvokeMessage>,
|
IRecipient<TelemetryBeginInvokeMessage>,
|
||||||
IRecipient<TelemetryInvokeResultMessage>,
|
IRecipient<TelemetryInvokeResultMessage>,
|
||||||
IRecipient<TelemetryExtensionInvokedMessage>,
|
IRecipient<TelemetryExtensionInvokedMessage>,
|
||||||
IRecipient<TelemetryDockConfigurationMessage>
|
IRecipient<TelemetryDockConfigurationMessage>,
|
||||||
|
IRecipient<TelemetrySearchResultsMessage>,
|
||||||
|
IRecipient<TelemetrySearchResultSelectedMessage>
|
||||||
{
|
{
|
||||||
public TelemetryForwarder()
|
public TelemetryForwarder()
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,8 @@ internal sealed class TelemetryForwarder :
|
|||||||
WeakReferenceMessenger.Default.Register<TelemetryInvokeResultMessage>(this);
|
WeakReferenceMessenger.Default.Register<TelemetryInvokeResultMessage>(this);
|
||||||
WeakReferenceMessenger.Default.Register<TelemetryExtensionInvokedMessage>(this);
|
WeakReferenceMessenger.Default.Register<TelemetryExtensionInvokedMessage>(this);
|
||||||
WeakReferenceMessenger.Default.Register<TelemetryDockConfigurationMessage>(this);
|
WeakReferenceMessenger.Default.Register<TelemetryDockConfigurationMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<TelemetrySearchResultsMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<TelemetrySearchResultSelectedMessage>(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message handlers for telemetry events from core layer
|
// Message handlers for telemetry events from core layer
|
||||||
@@ -68,6 +72,23 @@ internal sealed class TelemetryForwarder :
|
|||||||
message.EndBands));
|
message.EndBands));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Receive(TelemetrySearchResultsMessage message)
|
||||||
|
{
|
||||||
|
PowerToysTelemetry.Log.WriteEvent(new CmdPalSearchResults(
|
||||||
|
message.QueryLength,
|
||||||
|
message.ResultCount,
|
||||||
|
message.NoResults,
|
||||||
|
message.LatencyMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Receive(TelemetrySearchResultSelectedMessage message)
|
||||||
|
{
|
||||||
|
PowerToysTelemetry.Log.WriteEvent(new CmdPalSearchResultSelected(
|
||||||
|
message.QueryLength,
|
||||||
|
message.SelectedIndex,
|
||||||
|
message.SelectedTier.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
// Static method for logging session duration from UI layer
|
// Static method for logging session duration from UI layer
|
||||||
public static void LogSessionDuration(
|
public static void LogSessionDuration(
|
||||||
ulong durationMs,
|
ulong durationMs,
|
||||||
|
|||||||
@@ -79,6 +79,17 @@
|
|||||||
Value="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
Value="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||||
<controls:Case Value="True">
|
<controls:Case Value="True">
|
||||||
<StackPanel Orientation="Vertical">
|
<StackPanel Orientation="Vertical">
|
||||||
|
<controls:SettingsCard x:Name="SearchWeightSettingsCard" x:Uid="Settings_ExtensionPage_SearchWeight_SettingsCard">
|
||||||
|
<ComboBox
|
||||||
|
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||||
|
AutomationProperties.AutomationId="CmdPal_ExtensionPage_SearchWeight"
|
||||||
|
AutomationProperties.LabeledBy="{x:Bind SearchWeightSettingsCard}"
|
||||||
|
SelectedIndex="{x:Bind ViewModel.SearchWeightIndex, Mode=TwoWay}">
|
||||||
|
<ComboBoxItem x:Uid="Settings_ExtensionPage_SearchWeight_Lower" />
|
||||||
|
<ComboBoxItem x:Uid="Settings_ExtensionPage_SearchWeight_Normal" />
|
||||||
|
<ComboBoxItem x:Uid="Settings_ExtensionPage_SearchWeight_Higher" />
|
||||||
|
</ComboBox>
|
||||||
|
</controls:SettingsCard>
|
||||||
<TextBlock x:Uid="ExtensionCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
<TextBlock x:Uid="ExtensionCommandsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||||
<ItemsRepeater ItemsSource="{x:Bind ViewModel.TopLevelCommands, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}">
|
<ItemsRepeater ItemsSource="{x:Bind ViewModel.TopLevelCommands, Mode=OneWay}" Layout="{StaticResource VerticalStackLayout}">
|
||||||
<ItemsRepeater.ItemTemplate>
|
<ItemsRepeater.ItemTemplate>
|
||||||
|
|||||||
@@ -348,6 +348,24 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
|||||||
<data name="Settings_ExtensionPage_AliasActivation_SettingsCard.Description" xml:space="preserve">
|
<data name="Settings_ExtensionPage_AliasActivation_SettingsCard.Description" xml:space="preserve">
|
||||||
<value>Choose when the alias runs. Direct runs as soon as you type the alias. Indirect runs after a trailing space.</value>
|
<value>Choose when the alias runs. Direct runs as soon as you type the alias. Indirect runs after a trailing space.</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="Settings_ExtensionPage_SearchWeight_SettingsCard.Header" xml:space="preserve">
|
||||||
|
<value>Search weight</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings_ExtensionPage_SearchWeight_SettingsCard.Description" xml:space="preserve">
|
||||||
|
<value>Nudge this provider's results up or down among equally-relevant matches.</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings_ExtensionPage_SearchWeight_SettingsCard.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||||
|
<value>Search weight</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings_ExtensionPage_SearchWeight_Lower.Content" xml:space="preserve">
|
||||||
|
<value>Lower</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings_ExtensionPage_SearchWeight_Normal.Content" xml:space="preserve">
|
||||||
|
<value>Normal</value>
|
||||||
|
</data>
|
||||||
|
<data name="Settings_ExtensionPage_SearchWeight_Higher.Content" xml:space="preserve">
|
||||||
|
<value>Higher</value>
|
||||||
|
</data>
|
||||||
<data name="Settings_ExtensionPage_Builtin_SettingsCard.Header" xml:space="preserve">
|
<data name="Settings_ExtensionPage_Builtin_SettingsCard.Header" xml:space="preserve">
|
||||||
<value>Built-in</value>
|
<value>Built-in</value>
|
||||||
</data>
|
</data>
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
// 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 System.Linq;
|
||||||
|
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;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
[TestClass]
|
||||||
|
public sealed partial class EarlyFrameRelevanceTests
|
||||||
|
{
|
||||||
|
public TestContext TestContext { get; set; } = null!;
|
||||||
|
|
||||||
|
private sealed partial class CatalogItem : ListItem, IPrecomputedListItem
|
||||||
|
{
|
||||||
|
private FuzzyTargetCache _titleCache;
|
||||||
|
private FuzzyTargetCache _subtitleCache;
|
||||||
|
|
||||||
|
public CatalogItem(string title, string subtitle, string id)
|
||||||
|
: base(new NoOpCommand() { Id = id })
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Subtitle = subtitle;
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title);
|
||||||
|
|
||||||
|
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPrecomputedFuzzyMatcher CreateMatcher() => new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
|
||||||
|
|
||||||
|
private static ScoringFunction<IListItem> BuildScoringFunction(IRecentCommandsManager history, IPrecomputedFuzzyMatcher matcher)
|
||||||
|
=> (in FuzzyQuery query, IListItem item) => MainListPage.ScoreTopLevelItem(query, item, history, matcher, null);
|
||||||
|
|
||||||
|
private static RoScored<IListItem>[] Score(IReadOnlyList<CatalogItem> apps, string rawQuery, IRecentCommandsManager history, IPrecomputedFuzzyMatcher matcher)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(rawQuery);
|
||||||
|
var fn = BuildScoringFunction(history, matcher);
|
||||||
|
return InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>().ToArray(), query, fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RecentCommandsManager SeedUses(RecentCommandsManager history, string commandId, int uses)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < uses; i++)
|
||||||
|
{
|
||||||
|
history = history.WithHistoryItem(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every app matches "x" only at the Fuzzy tier.
|
||||||
|
private static CatalogItem[] BuildFuzzyOnlyCatalogForX() =>
|
||||||
|
[
|
||||||
|
new CatalogItem("Galaxy Store", "Shop for apps", "app.galaxy"),
|
||||||
|
new CatalogItem("Nexus Mods", "Manage game mods", "app.nexus"),
|
||||||
|
new CatalogItem("Toolbox Companion", "Developer tools", "app.toolbox"),
|
||||||
|
new CatalogItem("Max Cleaner", "Free up disk space", "app.max"),
|
||||||
|
new CatalogItem("Voxel Editor", "Edit voxel art", "app.voxel"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apps spanning multiple tiers for "c": some titles start with it, some have a non-leading word
|
||||||
|
// that does, and some only contain it mid-word.
|
||||||
|
private static CatalogItem[] BuildMixedCatalogForC() =>
|
||||||
|
[
|
||||||
|
new CatalogItem("Calculator", "Perform calculations", "app.calc"),
|
||||||
|
new CatalogItem("Calendar", "View your schedule", "app.cal"),
|
||||||
|
new CatalogItem("Visual Studio Code", "Code editor", "app.vscode"),
|
||||||
|
new CatalogItem("Windows Camera", "Take photos", "app.camera"),
|
||||||
|
new CatalogItem("Microsoft Edge", "Browse the web", "app.edge"),
|
||||||
|
new CatalogItem("Office Hub", "Productivity apps", "app.office"),
|
||||||
|
new CatalogItem("Discord", "Chat with friends", "app.discord"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The mechanism, locked as a test: when a 1-char query only matches mid-word, every result is
|
||||||
|
/// Fuzzy, so seeding frecency on any one of them floats it straight to rank 1.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ShortQuery_FrecencyFloatsWeakFuzzyMatchToTop()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var apps = BuildFuzzyOnlyCatalogForX();
|
||||||
|
|
||||||
|
// With no history, some app is at rank 1 purely on lexical quality.
|
||||||
|
var noHistory = Score(apps, "x", new RecentCommandsManager(), matcher);
|
||||||
|
Assert.IsTrue(noHistory.Length > 0, "The fuzzy-only catalog must still match 'x'.");
|
||||||
|
foreach (var s in noHistory)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(
|
||||||
|
RankTier.Fuzzy,
|
||||||
|
MainListRanker.TierOf(s.Score),
|
||||||
|
$"Every 'x' match must be Fuzzy tier; '{s.Item.Title}' was {MainListRanker.TierOf(s.Score)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed heavy frecency on an app that was NOT already at the top, and confirm it floats up.
|
||||||
|
var seededId = noHistory[^1].Item is CatalogItem last ? last.Id : throw new InvalidOperationException();
|
||||||
|
var seededTitle = noHistory[^1].Item.Title;
|
||||||
|
var history = SeedUses(new RecentCommandsManager(), seededId, 40);
|
||||||
|
|
||||||
|
var withHistory = Score(apps, "x", history, matcher);
|
||||||
|
|
||||||
|
TestContext.WriteLine($"no-history rank1='{noHistory[0].Item.Title}', seeded '{seededTitle}' -> rank1='{withHistory[0].Item.Title}'.");
|
||||||
|
|
||||||
|
Assert.AreEqual(RankTier.Fuzzy, MainListRanker.TierOf(withHistory[0].Score), "The floated rank-1 item is still only a Fuzzy match.");
|
||||||
|
Assert.AreEqual(seededTitle, withHistory[0].Item.Title, "Frecency should float the seeded weak match to rank 1 within the Fuzzy tier.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A short query filters every fuzzy-only app match.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ShortQuery_Filter_RemovesEveryFuzzyMatch()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var apps = BuildFuzzyOnlyCatalogForX();
|
||||||
|
|
||||||
|
var seededId = apps[0].Id;
|
||||||
|
var history = SeedUses(new RecentCommandsManager(), seededId, 40);
|
||||||
|
|
||||||
|
var scored = Score(apps, "x", history, matcher);
|
||||||
|
Assert.IsTrue(scored.Length > 0, "Precondition: the ungated result surfaces weak fuzzy matches.");
|
||||||
|
|
||||||
|
var filtered = MainListPage.FilterAppsForShortQueries(scored, queryLength: 1);
|
||||||
|
|
||||||
|
Assert.IsNotNull(filtered);
|
||||||
|
Assert.AreEqual(0, filtered!.Count, "A one-character query should filter fuzzy-only app matches.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The filter keeps high-confidence matches and removes fuzzy matches.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ShortQuery_Filter_KeepsHighConfidenceMatches()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var apps = BuildMixedCatalogForC();
|
||||||
|
|
||||||
|
var scored = Score(apps, "c", new RecentCommandsManager(), matcher);
|
||||||
|
|
||||||
|
var fuzzyCount = scored.Count(s => MainListRanker.TierOf(s.Score) == RankTier.Fuzzy);
|
||||||
|
var confidentCount = scored.Count(s => (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary);
|
||||||
|
|
||||||
|
Assert.IsTrue(fuzzyCount > 0, "Precondition: the mixed catalog produces some fuzzy-tail matches for 'c'.");
|
||||||
|
Assert.IsTrue(confidentCount > 0, "Precondition: the mixed catalog produces some confident matches for 'c'.");
|
||||||
|
|
||||||
|
var filtered = MainListPage.FilterAppsForShortQueries(scored, queryLength: 1);
|
||||||
|
Assert.IsNotNull(filtered);
|
||||||
|
|
||||||
|
Assert.AreEqual(confidentCount, filtered!.Count, "Only word-boundary or stronger matches should remain.");
|
||||||
|
foreach (var s in filtered)
|
||||||
|
{
|
||||||
|
Assert.IsTrue(
|
||||||
|
(int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary,
|
||||||
|
$"Filtered item '{s.Item.Title}' must be word-boundary tier or higher, was {MainListRanker.TierOf(s.Score)}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering preserves the scored order.
|
||||||
|
for (var i = 0; i < filtered.Count; i++)
|
||||||
|
{
|
||||||
|
Assert.AreSame(scored[i].Item, filtered[i].Item, $"Filtered item at index {i} must keep its scored position.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Queries longer than two characters return the original array.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void LongerQuery_Filter_ReturnsInputUnchanged()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var apps = BuildMixedCatalogForC();
|
||||||
|
|
||||||
|
foreach (var raw in new[] { "cal", "calc", "code" })
|
||||||
|
{
|
||||||
|
var scored = Score(apps, raw, new RecentCommandsManager(), matcher);
|
||||||
|
var filtered = MainListPage.FilterAppsForShortQueries(scored, raw.Length);
|
||||||
|
|
||||||
|
Assert.AreSame(scored, filtered, $"A {raw.Length}-character query ('{raw}') should return the original array.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The filter applies to query lengths one and two.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Filter_LengthBoundary_AppliesToOneAndTwo_NotThree()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var apps = BuildFuzzyOnlyCatalogForX();
|
||||||
|
var scored = Score(apps, "x", new RecentCommandsManager(), matcher);
|
||||||
|
Assert.IsTrue(scored.Length > 0, "Precondition: 'x' matches fuzzily.");
|
||||||
|
|
||||||
|
Assert.AreEqual(0, MainListPage.FilterAppsForShortQueries(scored, 1)!.Count, "Length 1 should be filtered.");
|
||||||
|
Assert.AreEqual(0, MainListPage.FilterAppsForShortQueries(scored, 2)!.Count, "Length 2 should be filtered.");
|
||||||
|
Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, 3), "Length 3 should not be filtered.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Null, empty, and default-view inputs are unchanged.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Filter_NullEmptyAndZeroLength_AreNoOps()
|
||||||
|
{
|
||||||
|
Assert.IsNull(MainListPage.FilterAppsForShortQueries(null, 1));
|
||||||
|
|
||||||
|
var empty = Array.Empty<RoScored<IListItem>>();
|
||||||
|
Assert.AreSame(empty, MainListPage.FilterAppsForShortQueries(empty, 1));
|
||||||
|
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var scored = Score(BuildFuzzyOnlyCatalogForX(), "x", new RecentCommandsManager(), matcher);
|
||||||
|
Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, 0), "A zero-length query should return the original array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Counts the leading entries at or above the requested tier.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void GetHighConfidenceAppsCount_CountsLeadingHighTierEntries()
|
||||||
|
{
|
||||||
|
RoScored<IListItem> Make(RankTier tier, int within)
|
||||||
|
=> new(new CatalogItem($"{tier}", string.Empty, $"{tier}.{within}"), MainListRanker.Pack(tier, within));
|
||||||
|
|
||||||
|
// Sorted descending by packed score: Exact(5) > Prefix(4) > WordBoundary(3) > Fuzzy(2) > Fallback(1).
|
||||||
|
var scored = new[]
|
||||||
|
{
|
||||||
|
Make(RankTier.ExactTitle, 100),
|
||||||
|
Make(RankTier.Prefix, 50),
|
||||||
|
Make(RankTier.AcronymWordBoundary, 20),
|
||||||
|
Make(RankTier.Fuzzy, 9000),
|
||||||
|
Make(RankTier.FallbackFloor, 5),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.AreEqual(5, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.FallbackFloor));
|
||||||
|
Assert.AreEqual(4, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.Fuzzy));
|
||||||
|
Assert.AreEqual(3, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.AcronymWordBoundary));
|
||||||
|
Assert.AreEqual(2, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.Prefix));
|
||||||
|
Assert.AreEqual(1, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.ExactTitle));
|
||||||
|
Assert.AreEqual(0, MainListPage.GetHighConfidenceAppsCount(scored, RankTier.AliasExact));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses the query length published with the scored array.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void Filter_UsesSuppliedPublishedLength()
|
||||||
|
{
|
||||||
|
RoScored<IListItem> Fuzzy(int within)
|
||||||
|
=> new(new CatalogItem($"fuzzy.{within}", string.Empty, $"fuzzy.{within}"), MainListRanker.Pack(RankTier.Fuzzy, within));
|
||||||
|
|
||||||
|
var scored = new[] { Fuzzy(30), Fuzzy(20), Fuzzy(10) };
|
||||||
|
|
||||||
|
var filteredByPublished = MainListPage.FilterAppsForShortQueries(scored, queryLength: 2);
|
||||||
|
Assert.IsNotNull(filteredByPublished);
|
||||||
|
Assert.AreEqual(0, filteredByPublished!.Count, "The published short length should filter fuzzy matches.");
|
||||||
|
|
||||||
|
Assert.AreSame(scored, MainListPage.FilterAppsForShortQueries(scored, queryLength: 5), "A longer published length should return the original array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Telemetry counts only visible apps after filtering.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void GetVisibleAppCount_ShortQuery_CountsOnlyFilteredApps()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var apps = BuildMixedCatalogForC();
|
||||||
|
var scored = Score(apps, "c", new RecentCommandsManager(), matcher);
|
||||||
|
|
||||||
|
var full = scored.Length;
|
||||||
|
var confident = scored.Count(s => (int)MainListRanker.TierOf(s.Score) >= (int)RankTier.AcronymWordBoundary);
|
||||||
|
Assert.IsTrue(confident < full, "Precondition: fuzzy matches make the filtered count smaller.");
|
||||||
|
|
||||||
|
const int NoCap = 1000;
|
||||||
|
|
||||||
|
Assert.AreEqual(confident, MainListPage.GetVisibleAppCount(scored, queryLength: 1, appResultLimit: NoCap));
|
||||||
|
Assert.AreEqual(confident, MainListPage.GetVisibleAppCount(scored, queryLength: 2, appResultLimit: NoCap));
|
||||||
|
|
||||||
|
Assert.AreEqual(full, MainListPage.GetVisibleAppCount(scored, queryLength: 3, appResultLimit: NoCap));
|
||||||
|
Assert.AreEqual(full, MainListPage.GetVisibleAppCount(scored, queryLength: 0, appResultLimit: NoCap), "A zero-length query should count the full set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The count respects the app limit and handles empty input.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void GetVisibleAppCount_RespectsCap_AndEmptyInput()
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
|
||||||
|
var fuzzyOnly = Score(BuildFuzzyOnlyCatalogForX(), "x", new RecentCommandsManager(), matcher);
|
||||||
|
Assert.IsTrue(fuzzyOnly.Length > 0, "Precondition: 'x' matches fuzzily.");
|
||||||
|
Assert.AreEqual(0, MainListPage.GetVisibleAppCount(fuzzyOnly, queryLength: 1, appResultLimit: 1000));
|
||||||
|
|
||||||
|
var mixed = Score(BuildMixedCatalogForC(), "c", new RecentCommandsManager(), matcher);
|
||||||
|
Assert.IsTrue(mixed.Length > 2, "Precondition: the mixed catalog has more than two matches so the cap bites.");
|
||||||
|
Assert.AreEqual(2, MainListPage.GetVisibleAppCount(mixed, queryLength: 3, appResultLimit: 2), "The app result limit should cap the count.");
|
||||||
|
|
||||||
|
Assert.AreEqual(0, MainListPage.GetVisibleAppCount(null, 1, 1000));
|
||||||
|
Assert.AreEqual(0, MainListPage.GetVisibleAppCount(Array.Empty<RoScored<IListItem>>(), 1, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// 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 Microsoft.CmdPal.UI.ViewModels;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Focused, per-tier unit tests for the <see cref="MainListRanker"/> primitives - tier
|
||||||
|
/// classification, the tier/within-tier packing invariants, and the within-tier score inputs.
|
||||||
|
/// These cover the pieces the end-to-end <see cref="RelevanceHarnessTests"/> cannot easily drive
|
||||||
|
/// through app/command mocks (alias-exact and fallback-floor classification, and the packing
|
||||||
|
/// guarantee that a higher tier always outranks a lower one regardless of within-tier score).
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public class MainListRankerTests
|
||||||
|
{
|
||||||
|
[DataTestMethod]
|
||||||
|
[DataRow("gh", "GitHub", true, true, false, false, RankTier.AliasExact, DisplayName = "Alias-exact is the strongest, most explicit signal and beats even a fallback flag")]
|
||||||
|
[DataRow("anything", "Some Fallback", true, false, false, true, RankTier.FallbackFloor, DisplayName = "A fallback that is not alias-exact lands on the floor regardless of a lexical match")]
|
||||||
|
[DataRow("calculator", "Calculator", false, false, false, true, RankTier.ExactTitle, DisplayName = "Exact title match")]
|
||||||
|
[DataRow("cal", "Calculator", false, false, false, true, RankTier.Prefix, DisplayName = "Title prefix match")]
|
||||||
|
[DataRow("code", "Visual Studio Code", false, false, false, true, RankTier.AcronymWordBoundary, DisplayName = "Word-boundary match")]
|
||||||
|
[DataRow("vs", "Visual Studio Code", false, false, false, true, RankTier.AcronymWordBoundary, DisplayName = "Acronym match")]
|
||||||
|
[DataRow("cmd", "Command Prompt", false, false, false, true, RankTier.Fuzzy, DisplayName = "A lexical match that is not exact/prefix/word-boundary/acronym is fuzzy")]
|
||||||
|
[DataRow("zzz", "Command Prompt", false, false, false, false, RankTier.None, DisplayName = "Nothing matched")]
|
||||||
|
[DataRow("zz", "Some Command", false, false, true, false, RankTier.Fuzzy, DisplayName = "An alias-substring match keeps the item at the fuzzy floor even with no lexical match")]
|
||||||
|
public void ClassifyTier_ClassifiesEachSignalIntoItsTier(
|
||||||
|
string query,
|
||||||
|
string title,
|
||||||
|
bool isFallback,
|
||||||
|
bool isAliasExact,
|
||||||
|
bool isAliasSubstringMatch,
|
||||||
|
bool matchedLexically,
|
||||||
|
RankTier expected)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(
|
||||||
|
expected,
|
||||||
|
MainListRanker.ClassifyTier(query, title, isFallback, isAliasExact, isAliasSubstringMatch, matchedLexically));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Pack_HigherTierAlwaysOutranksLowerTier()
|
||||||
|
{
|
||||||
|
// The core invariant: a higher tier with the WORST possible within-tier score still
|
||||||
|
// outranks a lower tier with the BEST possible within-tier score. This is what makes
|
||||||
|
// "an exact match always beats a fuzzy one" true no matter how much frecency piles up.
|
||||||
|
RankTier[] ascending =
|
||||||
|
{
|
||||||
|
RankTier.FallbackFloor,
|
||||||
|
RankTier.Fuzzy,
|
||||||
|
RankTier.AcronymWordBoundary,
|
||||||
|
RankTier.Prefix,
|
||||||
|
RankTier.ExactTitle,
|
||||||
|
RankTier.AliasExact,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (var i = 0; i < ascending.Length - 1; i++)
|
||||||
|
{
|
||||||
|
var lower = MainListRanker.Pack(ascending[i], MainListRanker.TierStride - 1);
|
||||||
|
var higher = MainListRanker.Pack(ascending[i + 1], 0);
|
||||||
|
Assert.IsTrue(
|
||||||
|
higher > lower,
|
||||||
|
$"{ascending[i + 1]} (min within-tier) must outrank {ascending[i]} (max within-tier)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Pack_NoneIsZeroAndFiltered()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(0, MainListRanker.Pack(RankTier.None, 999_999));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Pack_WithinTierScoreIsClampedToItsBand()
|
||||||
|
{
|
||||||
|
// An absurd within-tier score must never spill into the next tier's band.
|
||||||
|
var packed = MainListRanker.Pack(RankTier.Fuzzy, double.MaxValue);
|
||||||
|
Assert.AreEqual(RankTier.Fuzzy, MainListRanker.TierOf(packed));
|
||||||
|
|
||||||
|
var nextTierFloor = MainListRanker.Pack(RankTier.AcronymWordBoundary, 0);
|
||||||
|
Assert.IsTrue(packed < nextTierFloor, "A clamped within-tier score must stay below the next tier");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void Pack_WithinTierScoreOrdersItemsInTheSameTier()
|
||||||
|
{
|
||||||
|
var low = MainListRanker.Pack(RankTier.Prefix, 10);
|
||||||
|
var high = MainListRanker.Pack(RankTier.Prefix, 20);
|
||||||
|
Assert.IsTrue(high > low, "Within the same tier, a higher within-tier score sorts higher");
|
||||||
|
Assert.AreEqual(MainListRanker.TierOf(low), MainListRanker.TierOf(high), "Both remain in the same tier");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TierOf_RoundTripsEveryTier()
|
||||||
|
{
|
||||||
|
foreach (RankTier tier in Enum.GetValues(typeof(RankTier)))
|
||||||
|
{
|
||||||
|
if (tier == RankTier.None)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var packed = MainListRanker.Pack(tier, 42);
|
||||||
|
Assert.AreEqual(tier, MainListRanker.TierOf(packed), $"Packing then unpacking {tier} should round-trip");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void WithinTierScore_LexicalQualityLeads()
|
||||||
|
{
|
||||||
|
// More lexical quality raises the within-tier score, all else equal.
|
||||||
|
var lowLexical = MainListRanker.WithinTierScore(lexicalQuality: 5, frecencyWeight: 0, aliasSubstringBonus: 0, providerBonus: 0);
|
||||||
|
var highLexical = MainListRanker.WithinTierScore(lexicalQuality: 6, frecencyWeight: 0, aliasSubstringBonus: 0, providerBonus: 0);
|
||||||
|
Assert.IsTrue(highLexical > lowLexical, "Higher lexical quality should raise the within-tier score");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void WithinTierScore_FrecencyBreaksTies()
|
||||||
|
{
|
||||||
|
var noFrecency = MainListRanker.WithinTierScore(lexicalQuality: 5, frecencyWeight: 0, aliasSubstringBonus: 0, providerBonus: 0);
|
||||||
|
var withFrecency = MainListRanker.WithinTierScore(lexicalQuality: 5, frecencyWeight: 3, aliasSubstringBonus: 0, providerBonus: 0);
|
||||||
|
Assert.IsTrue(withFrecency > noFrecency, "Frecency should raise the within-tier score for otherwise-equal items");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ProviderBonus_LowerIsBelowNormalIsBelowHigher()
|
||||||
|
{
|
||||||
|
Assert.IsTrue(
|
||||||
|
MainListRanker.ProviderBonus(ProviderSearchWeight.Lower) < MainListRanker.ProviderBonus(ProviderSearchWeight.Normal),
|
||||||
|
"Lower should subtract relative to Normal");
|
||||||
|
Assert.IsTrue(
|
||||||
|
MainListRanker.ProviderBonus(ProviderSearchWeight.Normal) < MainListRanker.ProviderBonus(ProviderSearchWeight.Higher),
|
||||||
|
"Higher should add relative to Normal");
|
||||||
|
Assert.AreEqual(0.0, MainListRanker.ProviderBonus(ProviderSearchWeight.Normal), "Normal is the neutral default");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// 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 System.Collections.Immutable;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.CmdPal.Common.Text;
|
||||||
|
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
using Windows.Foundation;
|
||||||
|
using WyHash;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
[TestClass]
|
||||||
|
public partial class ProviderWeightingTests : CommandPaletteUnitTestBase
|
||||||
|
{
|
||||||
|
private static IPrecomputedFuzzyMatcher CreateMatcher()
|
||||||
|
=> new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
|
||||||
|
|
||||||
|
private static RecentCommandsManager EmptyHistory() => new();
|
||||||
|
|
||||||
|
// Maps each item to the weight configured for its provider id.
|
||||||
|
private static Func<IListItem, ProviderSearchWeight> LookupBy(IReadOnlyDictionary<string, ProviderSearchWeight> byProvider)
|
||||||
|
=> item =>
|
||||||
|
{
|
||||||
|
var providerId = item is WeightItemMock mock ? mock.ProviderId ?? string.Empty : string.Empty;
|
||||||
|
return byProvider.TryGetValue(providerId, out var weight) ? weight : ProviderSearchWeight.Normal;
|
||||||
|
};
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ProviderBonus_MapsSignedMagnitude()
|
||||||
|
{
|
||||||
|
Assert.AreEqual(-MainListRanker.ProviderWeightBonus, MainListRanker.ProviderBonus(ProviderSearchWeight.Lower));
|
||||||
|
Assert.AreEqual(0.0, MainListRanker.ProviderBonus(ProviderSearchWeight.Normal));
|
||||||
|
Assert.AreEqual(MainListRanker.ProviderWeightBonus, MainListRanker.ProviderBonus(ProviderSearchWeight.Higher));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ProviderWeight_ReordersWithinTier()
|
||||||
|
{
|
||||||
|
// Two items that land in the SAME tier (exact-title) with identical lexical quality
|
||||||
|
// and no history. The only differentiator is the per-provider weight.
|
||||||
|
var fuzzyMatcher = CreateMatcher();
|
||||||
|
var q = fuzzyMatcher.PrecomputeQuery("git");
|
||||||
|
|
||||||
|
var itemA = new WeightItemMock("git", ProviderId: "providerA");
|
||||||
|
var itemB = new WeightItemMock("git", ProviderId: "providerB");
|
||||||
|
|
||||||
|
// Baseline: both Normal -> equal scores.
|
||||||
|
var neutral = LookupBy(new Dictionary<string, ProviderSearchWeight>());
|
||||||
|
var baseA = MainListPage.ScoreTopLevelItem(q, itemA, EmptyHistory(), fuzzyMatcher, neutral);
|
||||||
|
var baseB = MainListPage.ScoreTopLevelItem(q, itemB, EmptyHistory(), fuzzyMatcher, neutral);
|
||||||
|
Assert.AreEqual(baseA, baseB, "With both providers Normal, tied items should score equally");
|
||||||
|
|
||||||
|
// A Higher, B Lower -> A must now sort above B.
|
||||||
|
var lookup = LookupBy(new Dictionary<string, ProviderSearchWeight>
|
||||||
|
{
|
||||||
|
["providerA"] = ProviderSearchWeight.Higher,
|
||||||
|
["providerB"] = ProviderSearchWeight.Lower,
|
||||||
|
});
|
||||||
|
var higherA = MainListPage.ScoreTopLevelItem(q, itemA, EmptyHistory(), fuzzyMatcher, lookup);
|
||||||
|
var lowerB = MainListPage.ScoreTopLevelItem(q, itemB, EmptyHistory(), fuzzyMatcher, lookup);
|
||||||
|
|
||||||
|
Assert.IsTrue(higherA > baseA, "Higher weight should raise the score");
|
||||||
|
Assert.IsTrue(lowerB < baseB, "Lower weight should reduce the score");
|
||||||
|
Assert.IsTrue(higherA > lowerB, "Higher-weighted provider should outrank the lower-weighted one within the tier");
|
||||||
|
|
||||||
|
// Same tier the whole time - the nudge only reordered within it.
|
||||||
|
Assert.AreEqual(MainListRanker.TierOf(baseA), MainListRanker.TierOf(higherA));
|
||||||
|
Assert.AreEqual(MainListRanker.TierOf(baseB), MainListRanker.TierOf(lowerB));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ProviderWeight_NeverCrossesTierBoundary()
|
||||||
|
{
|
||||||
|
var fuzzyMatcher = CreateMatcher();
|
||||||
|
var q = fuzzyMatcher.PrecomputeQuery("code");
|
||||||
|
|
||||||
|
// "code" exactly matches -> ExactTitle tier. Give it the WORST weight.
|
||||||
|
var exact = new WeightItemMock("code", ProviderId: "weak");
|
||||||
|
|
||||||
|
// "Visual Studio Code" only matches "code" at a word boundary -> lower tier. Give it
|
||||||
|
// the BEST weight, plus a pile of history, to try to jump the tier boundary.
|
||||||
|
var lowerTier = new WeightItemMock("Visual Studio Code", ProviderId: "strong");
|
||||||
|
|
||||||
|
var lookup = LookupBy(new Dictionary<string, ProviderSearchWeight>
|
||||||
|
{
|
||||||
|
["weak"] = ProviderSearchWeight.Lower,
|
||||||
|
["strong"] = ProviderSearchWeight.Higher,
|
||||||
|
});
|
||||||
|
|
||||||
|
var history = EmptyHistory();
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
history = history.WithHistoryItem(lowerTier.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var exactScore = MainListPage.ScoreTopLevelItem(q, exact, EmptyHistory(), fuzzyMatcher, lookup);
|
||||||
|
var lowerScore = MainListPage.ScoreTopLevelItem(q, lowerTier, history, fuzzyMatcher, lookup);
|
||||||
|
|
||||||
|
Assert.IsTrue(
|
||||||
|
MainListRanker.TierOf(exactScore) > MainListRanker.TierOf(lowerScore),
|
||||||
|
"The exact match must live in a strictly higher tier");
|
||||||
|
Assert.IsTrue(
|
||||||
|
exactScore > lowerScore,
|
||||||
|
"A within-tier nudge (even Higher + heavy history) must never promote an item across a tier boundary");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ProviderWeight_DefaultNormalMatchesNoLookup()
|
||||||
|
{
|
||||||
|
var fuzzyMatcher = CreateMatcher();
|
||||||
|
var q = fuzzyMatcher.PrecomputeQuery("term");
|
||||||
|
var item = new WeightItemMock("Terminal", ProviderId: "providerA");
|
||||||
|
|
||||||
|
// No lookup at all should behave exactly like an all-Normal lookup, which should
|
||||||
|
// behave exactly like the previous (provider-unaware) scoring.
|
||||||
|
var noLookup = MainListPage.ScoreTopLevelItem(q, item, EmptyHistory(), fuzzyMatcher);
|
||||||
|
var normalLookup = MainListPage.ScoreTopLevelItem(
|
||||||
|
q,
|
||||||
|
item,
|
||||||
|
EmptyHistory(),
|
||||||
|
fuzzyMatcher,
|
||||||
|
LookupBy(new Dictionary<string, ProviderSearchWeight> { ["providerA"] = ProviderSearchWeight.Normal }));
|
||||||
|
|
||||||
|
Assert.AreEqual(noLookup, normalLookup, "Normal weight must be a no-op relative to the default path");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ProviderWeight_AppliesToAnyProviderItem()
|
||||||
|
{
|
||||||
|
// App items are plain IListItems (not TopLevelViewModel). This asserts the scorer
|
||||||
|
// honors the provider weight for ANY item, which is how installed apps (the "AllApps"
|
||||||
|
// provider) get nudged.
|
||||||
|
var fuzzyMatcher = CreateMatcher();
|
||||||
|
var q = fuzzyMatcher.PrecomputeQuery("note");
|
||||||
|
var appItem = new WeightItemMock("Notepad", ProviderId: "AllApps");
|
||||||
|
|
||||||
|
var normal = MainListPage.ScoreTopLevelItem(q, appItem, EmptyHistory(), fuzzyMatcher);
|
||||||
|
var higher = MainListPage.ScoreTopLevelItem(
|
||||||
|
q,
|
||||||
|
appItem,
|
||||||
|
EmptyHistory(),
|
||||||
|
fuzzyMatcher,
|
||||||
|
LookupBy(new Dictionary<string, ProviderSearchWeight> { ["AllApps"] = ProviderSearchWeight.Higher }));
|
||||||
|
|
||||||
|
Assert.IsTrue(higher > normal, "An app-style item should also respond to its provider's Higher weight");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SearchWeight_SerializationRoundTrips()
|
||||||
|
{
|
||||||
|
var dict = ImmutableDictionary<string, ProviderSettings>.Empty
|
||||||
|
.SetItem("p", new ProviderSettings { SearchWeight = ProviderSearchWeight.Higher });
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(dict, JsonSerializationContext.Default.ImmutableProviderSettingsDictionary);
|
||||||
|
var restored = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.ImmutableProviderSettingsDictionary);
|
||||||
|
|
||||||
|
Assert.IsNotNull(restored);
|
||||||
|
Assert.AreEqual(ProviderSearchWeight.Higher, restored!["p"].SearchWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SearchWeight_LegacyJsonDeserializesToNormal()
|
||||||
|
{
|
||||||
|
// Legacy persisted settings predate SearchWeight; the missing property must default
|
||||||
|
// to Normal rather than throwing or landing on Lower.
|
||||||
|
const string legacyJson = "{\"p\":{\"IsEnabled\":true}}";
|
||||||
|
|
||||||
|
var restored = JsonSerializer.Deserialize(legacyJson, JsonSerializationContext.Default.ImmutableProviderSettingsDictionary);
|
||||||
|
|
||||||
|
Assert.IsNotNull(restored);
|
||||||
|
Assert.AreEqual(ProviderSearchWeight.Normal, restored!["p"].SearchWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed partial record WeightItemMock(
|
||||||
|
string Title,
|
||||||
|
string? Subtitle = "",
|
||||||
|
string? GivenId = "",
|
||||||
|
string? ProviderId = "") : IListItem
|
||||||
|
{
|
||||||
|
public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId;
|
||||||
|
|
||||||
|
public IDetails Details => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public string Section => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public ITag[] Tags => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public string TextToSuggest => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public ICommand Command => new NoOpCommand() { Id = Id };
|
||||||
|
|
||||||
|
public IIconInfo Icon => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public IContextItem[] MoreCommands => throw new NotImplementedException();
|
||||||
|
|
||||||
|
#pragma warning disable CS0067
|
||||||
|
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||||
|
#pragma warning restore CS0067
|
||||||
|
|
||||||
|
private string GenerateId()
|
||||||
|
{
|
||||||
|
var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0);
|
||||||
|
return $"{ProviderId}{result}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.Immutable;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.CmdPal.Common.Text;
|
using Microsoft.CmdPal.Common.Text;
|
||||||
using Microsoft.CmdPal.Ext.UnitTestBase;
|
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||||
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
@@ -33,20 +35,6 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
|||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RecentCommandsManager CreateBasicHistoryService()
|
|
||||||
{
|
|
||||||
var commonCommands = new List<string>
|
|
||||||
{
|
|
||||||
"com.microsoft.cmdpal.shell",
|
|
||||||
"com.microsoft.cmdpal.windowwalker",
|
|
||||||
"Visual Studio 2022 Preview_6533433915015224980",
|
|
||||||
"com.microsoft.cmdpal.reload",
|
|
||||||
"com.microsoft.cmdpal.shell",
|
|
||||||
};
|
|
||||||
|
|
||||||
return CreateHistory(commonCommands);
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void ValidateHistoryFunctionality()
|
public void ValidateHistoryFunctionality()
|
||||||
{
|
{
|
||||||
@@ -63,21 +51,31 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
|||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void ValidateHistoryWeighting()
|
public void ValidateHistoryWeighting()
|
||||||
{
|
{
|
||||||
// Setup
|
// Build history with explicit, strictly-increasing timestamps so time-decay is
|
||||||
var history = CreateBasicHistoryService();
|
// deterministic. "shell" is used twice (most uses) and most recently; the others are
|
||||||
|
// each used once, progressively more recently.
|
||||||
|
var t0 = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var history = new RecentCommandsManager();
|
||||||
|
history = history.WithHistoryItem("com.microsoft.cmdpal.shell", t0);
|
||||||
|
history = history.WithHistoryItem("com.microsoft.cmdpal.windowwalker", t0.AddDays(1));
|
||||||
|
history = history.WithHistoryItem("Visual Studio 2022 Preview_6533433915015224980", t0.AddDays(2));
|
||||||
|
history = history.WithHistoryItem("com.microsoft.cmdpal.reload", t0.AddDays(3));
|
||||||
|
history = history.WithHistoryItem("com.microsoft.cmdpal.shell", t0.AddDays(4));
|
||||||
|
|
||||||
|
var now = t0.AddDays(4);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell");
|
var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell", now);
|
||||||
var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker");
|
var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker", now);
|
||||||
var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980");
|
var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980", now);
|
||||||
var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload");
|
var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload", now);
|
||||||
var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command");
|
var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command", now);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses");
|
Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell is both the most-used and most-recent command");
|
||||||
Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency");
|
Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio was used more recently than Window Walker");
|
||||||
Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight");
|
Assert.IsTrue(reloadWeight > vsWeight, "Reload was used more recently than Visual Studio");
|
||||||
Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency");
|
Assert.IsTrue(shellWeight > vsWeight, "Shell is both more recent and more frequently used than Visual Studio");
|
||||||
Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight");
|
Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,97 +157,163 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void ValidateHistoryBuckets()
|
public void ValidateRecencyDecay()
|
||||||
{
|
{
|
||||||
// Setup
|
// Each command is used exactly once, at progressively older times. Weight must decay
|
||||||
// (these will be checked in reverse order, so that A is the most recent)
|
// monotonically with age, and a 3-day-old use (one half-life) should weigh about half
|
||||||
var items = new List<ListItemMock>
|
// of a use that just happened.
|
||||||
{
|
var now = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0
|
var history = new RecentCommandsManager()
|
||||||
new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0
|
.WithHistoryItem("today", now)
|
||||||
new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0
|
.WithHistoryItem("three-days", now.AddDays(-3))
|
||||||
new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1
|
.WithHistoryItem("ten-days", now.AddDays(-10))
|
||||||
new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1
|
.WithHistoryItem("thirty-days", now.AddDays(-30));
|
||||||
new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1
|
|
||||||
new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1
|
|
||||||
new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1
|
|
||||||
new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1
|
|
||||||
new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1
|
|
||||||
new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1
|
|
||||||
new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2
|
|
||||||
new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2
|
|
||||||
new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2
|
|
||||||
new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2
|
|
||||||
};
|
|
||||||
|
|
||||||
for (var i = items.Count; i <= 50; i++)
|
var today = history.GetCommandHistoryWeight("today", now);
|
||||||
|
var threeDays = history.GetCommandHistoryWeight("three-days", now);
|
||||||
|
var tenDays = history.GetCommandHistoryWeight("ten-days", now);
|
||||||
|
var thirtyDays = history.GetCommandHistoryWeight("thirty-days", now);
|
||||||
|
|
||||||
|
Assert.IsTrue(today > threeDays, "A more recent use must weigh more than an older one");
|
||||||
|
Assert.IsTrue(threeDays > tenDays, "Decay must be monotonic with age");
|
||||||
|
Assert.IsTrue(tenDays > thirtyDays, "Older uses keep decaying toward zero");
|
||||||
|
Assert.IsTrue(thirtyDays >= 0, "Weight must never go negative");
|
||||||
|
|
||||||
|
// The half-life is 3 days, so a 3-day-old single use is about half of a fresh one.
|
||||||
|
Assert.AreEqual(today / 2.0, threeDays, 1.0, "Three days should be a single half-life");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ValidateFrequencyWeighting()
|
||||||
|
{
|
||||||
|
// Hold recency constant (same timestamp) and vary only the use count. More uses must
|
||||||
|
// weigh more, but the log-scaled frequency term stays within the documented cap.
|
||||||
|
var now = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var history = new RecentCommandsManager().WithHistoryItem("once", now);
|
||||||
|
for (var i = 0; i < 7; i++)
|
||||||
{
|
{
|
||||||
items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}"));
|
history = history.WithHistoryItem("many", now);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Act
|
var once = history.GetCommandHistoryWeight("once", now);
|
||||||
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
|
var many = history.GetCommandHistoryWeight("many", now);
|
||||||
|
|
||||||
// Assert
|
Assert.IsTrue(many > once, "At equal recency, a more frequently used command weighs more");
|
||||||
// First three items should be in the top bucket
|
Assert.IsTrue(many <= RecentCommandsManager.MaxWeight, "Weight stays within the documented cap");
|
||||||
var weightA = history.GetCommandHistoryWeight("idA");
|
}
|
||||||
var weightB = history.GetCommandHistoryWeight("idB");
|
|
||||||
var weightC = history.GetCommandHistoryWeight("idC");
|
|
||||||
|
|
||||||
Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands");
|
[TestMethod]
|
||||||
Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands");
|
public void ValidateLookupIndexStaysConsistentAcrossChanges()
|
||||||
|
{
|
||||||
|
// The commandId lookup is backed by a cached index that must be invalidated whenever
|
||||||
|
// history changes (WithHistoryItem returns a new record, and a 'with' copy carries the
|
||||||
|
// old cached field over). Force the index to build on one instance, then mutate, and
|
||||||
|
// confirm each instance reports exactly its own history.
|
||||||
|
var now = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var first = new RecentCommandsManager().WithHistoryItem("alpha", now);
|
||||||
|
|
||||||
// Next eight items (3-10 inclusive) should be in the second bucket
|
// Read once to build the cached index on 'first'.
|
||||||
var weightD = history.GetCommandHistoryWeight("idD");
|
var alphaFirst = first.GetCommandHistoryWeight("alpha", now);
|
||||||
var weightE = history.GetCommandHistoryWeight("idE");
|
Assert.IsTrue(alphaFirst > 0, "alpha should have weight on the first instance");
|
||||||
var weightF = history.GetCommandHistoryWeight("idF");
|
Assert.AreEqual(0, first.GetCommandHistoryWeight("beta", now), "beta is not in the first instance");
|
||||||
var weightG = history.GetCommandHistoryWeight("idG");
|
|
||||||
var weightH = history.GetCommandHistoryWeight("idH");
|
|
||||||
var weightI = history.GetCommandHistoryWeight("idI");
|
|
||||||
var weightJ = history.GetCommandHistoryWeight("idJ");
|
|
||||||
var weightK = history.GetCommandHistoryWeight("idK");
|
|
||||||
|
|
||||||
Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands");
|
// Add a use of alpha and a brand-new beta, producing a new instance.
|
||||||
Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands");
|
var second = first
|
||||||
Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands");
|
.WithHistoryItem("alpha", now.AddDays(1))
|
||||||
Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands");
|
.WithHistoryItem("beta", now.AddDays(1));
|
||||||
Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands");
|
|
||||||
Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands");
|
|
||||||
Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands");
|
|
||||||
|
|
||||||
// Items up to the 15th should be in the third bucket
|
// The new instance sees the newer alpha (more recent + higher use) and the new beta.
|
||||||
var weightL = history.GetCommandHistoryWeight("idL");
|
Assert.IsTrue(
|
||||||
var weightM = history.GetCommandHistoryWeight("idM");
|
second.GetCommandHistoryWeight("alpha", now.AddDays(1)) > 0,
|
||||||
var weightN = history.GetCommandHistoryWeight("idN");
|
"alpha should still resolve on the updated instance");
|
||||||
var weightO = history.GetCommandHistoryWeight("idO");
|
Assert.IsTrue(
|
||||||
var weight15 = history.GetCommandHistoryWeight("id15");
|
second.GetCommandHistoryWeight("beta", now.AddDays(1)) > 0,
|
||||||
Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands");
|
"beta should resolve on the updated instance after its index rebuilds");
|
||||||
Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands");
|
|
||||||
Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands");
|
|
||||||
Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands");
|
|
||||||
|
|
||||||
// Items after that should be in the lowest buckets
|
// The original instance is unchanged - its index never gained beta.
|
||||||
var weight0 = history.GetCommandHistoryWeight(items[0].Id);
|
Assert.AreEqual(0, first.GetCommandHistoryWeight("beta", now), "the original instance must not see beta");
|
||||||
var weight3 = history.GetCommandHistoryWeight(items[3].Id);
|
}
|
||||||
var weight11 = history.GetCommandHistoryWeight(items[11].Id);
|
|
||||||
var weight16 = history.GetCommandHistoryWeight("id16");
|
|
||||||
var weight20 = history.GetCommandHistoryWeight("id20");
|
|
||||||
var weight30 = history.GetCommandHistoryWeight("id30");
|
|
||||||
var weight40 = history.GetCommandHistoryWeight("id40");
|
|
||||||
var weight49 = history.GetCommandHistoryWeight("id49");
|
|
||||||
|
|
||||||
Assert.IsTrue(weight0 > weight3);
|
[TestMethod]
|
||||||
Assert.IsTrue(weight3 > weight11);
|
public void ValidateHistoryCap()
|
||||||
Assert.IsTrue(weight11 > weight16);
|
{
|
||||||
|
// Insert well beyond the cap, each newer than the last. The store keeps the most-recent
|
||||||
|
// entries and evicts the oldest, replacing the previous 50-entry limit.
|
||||||
|
var now = new DateTimeOffset(2025, 2, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var history = new RecentCommandsManager();
|
||||||
|
|
||||||
Assert.AreEqual(weight16, weight20);
|
var total = RecentCommandsManager.MaxHistoryEntries + 50;
|
||||||
Assert.AreEqual(weight20, weight30);
|
for (var i = 0; i < total; i++)
|
||||||
Assert.IsTrue(weight30 > weight40);
|
{
|
||||||
Assert.AreEqual(weight40, weight49);
|
history = history.WithHistoryItem($"cmd-{i}", now.AddMinutes(i));
|
||||||
|
}
|
||||||
|
|
||||||
// The 50th item has fallen out of the list now
|
var evaluatedAt = now.AddMinutes(total);
|
||||||
var weight50 = history.GetCommandHistoryWeight("id50");
|
|
||||||
Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list");
|
Assert.IsTrue(
|
||||||
|
history.History.Count <= RecentCommandsManager.MaxHistoryEntries,
|
||||||
|
"History should be capped at MaxHistoryEntries");
|
||||||
|
|
||||||
|
// The earliest (now-evicted) entries have fallen out of the store.
|
||||||
|
Assert.AreEqual(0, history.GetCommandHistoryWeight("cmd-0", evaluatedAt), "The oldest entry should have been evicted");
|
||||||
|
|
||||||
|
// The most-recent entries survive and still carry weight.
|
||||||
|
Assert.IsTrue(
|
||||||
|
history.GetCommandHistoryWeight($"cmd-{total - 1}", evaluatedAt) > 0,
|
||||||
|
"The most recent entry should be retained");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ValidateLegacyHistoryMigration()
|
||||||
|
{
|
||||||
|
// Legacy items persisted before LastUsed existed deserialize with a default timestamp.
|
||||||
|
// They should be mildly backdated so ordering falls back to Uses (frequency) rather
|
||||||
|
// than collapsing to all-equal or zero.
|
||||||
|
var now = new DateTimeOffset(2025, 5, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var legacy = new RecentCommandsManager
|
||||||
|
{
|
||||||
|
History = ImmutableList.Create(
|
||||||
|
new HistoryItem { CommandId = "rare", Uses = 1 },
|
||||||
|
new HistoryItem { CommandId = "common", Uses = 8 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
var rare = legacy.GetCommandHistoryWeight("rare", now);
|
||||||
|
var common = legacy.GetCommandHistoryWeight("common", now);
|
||||||
|
|
||||||
|
Assert.IsTrue(rare > 0, "Legacy items should be mildly backdated, not zeroed out");
|
||||||
|
Assert.IsTrue(common > rare, "Legacy ordering should fall back to Uses (frequency)");
|
||||||
|
|
||||||
|
// A brand-new, single real use should outrank a backdated single-use legacy item.
|
||||||
|
var withFresh = legacy.WithHistoryItem("brand-new", now);
|
||||||
|
var fresh = withFresh.GetCommandHistoryWeight("brand-new", now);
|
||||||
|
Assert.IsTrue(fresh > rare, "A just-used command should outrank a backdated single-use legacy item");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ValidateHistorySerializationRoundTrips()
|
||||||
|
{
|
||||||
|
// The persisted history (see SettingsModel's JsonSerializable context) must round-trip,
|
||||||
|
// including the new LastUsed timestamp, so decay survives a save/load cycle.
|
||||||
|
var now = new DateTimeOffset(2025, 3, 15, 12, 30, 0, TimeSpan.Zero);
|
||||||
|
var original = new RecentCommandsManager()
|
||||||
|
.WithHistoryItem("alpha", now)
|
||||||
|
.WithHistoryItem("beta", now.AddMinutes(5));
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(original, JsonSerializationContext.Default.RecentCommandsManager);
|
||||||
|
var restored = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.RecentCommandsManager);
|
||||||
|
|
||||||
|
Assert.IsNotNull(restored, "Round-tripped history should not be null");
|
||||||
|
Assert.AreEqual(2, restored!.History.Count, "All history entries should round-trip");
|
||||||
|
|
||||||
|
var beta = restored.History.First(h => h.CommandId == "beta");
|
||||||
|
Assert.AreEqual(now.AddMinutes(5), beta.LastUsed, "The LastUsed timestamp should round-trip");
|
||||||
|
Assert.AreEqual(1, beta.Uses, "The use count should round-trip");
|
||||||
|
|
||||||
|
// Weights computed from the restored state should match the original.
|
||||||
|
Assert.AreEqual(
|
||||||
|
original.GetCommandHistoryWeight("beta", now.AddMinutes(5)),
|
||||||
|
restored.GetCommandHistoryWeight("beta", now.AddMinutes(5)),
|
||||||
|
"Restored history should produce the same weight as the original");
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
// 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 System.Linq;
|
||||||
|
using Microsoft.CmdPal.Common.Text;
|
||||||
|
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
using WyHash;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// End-to-end relevance harness for the main/root page ranker. Every case is expressed as a
|
||||||
|
/// realistic query paired with an ordering constraint (rank-1, or "X must rank above Y") and
|
||||||
|
/// asserted against the REAL <see cref="MainListPage.ScoreTopLevelItem"/> scoring path, sorted
|
||||||
|
/// exactly the way the product sorts (positive scores, descending). The intent is to lock in
|
||||||
|
/// "results seem logical and relevant" as an objective, extendable yardstick: to add a new
|
||||||
|
/// scenario, drop another entry in the fixture and another constraint in a test.
|
||||||
|
///
|
||||||
|
/// Focused, per-tier unit tests for the <see cref="MainListRanker"/> primitives live alongside
|
||||||
|
/// this harness in <see cref="MainListRankerTests"/>.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public partial class RelevanceHarnessTests : CommandPaletteUnitTestBase
|
||||||
|
{
|
||||||
|
// A lightweight top-level-command / installed-app stand-in built on the real ListItem
|
||||||
|
// toolkit type, so the harness drives the same scoring path as the product (as the sibling
|
||||||
|
// RecentCommandsTests does) rather than a reimplementation. ProviderId lets a test key a
|
||||||
|
// per-provider weight lookup; Id is derived deterministically so frecency history can target it.
|
||||||
|
private sealed partial class ListItemMock : ListItem
|
||||||
|
{
|
||||||
|
public ListItemMock(string title, string? subtitle = "", string? givenId = "", string? providerId = "")
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Subtitle = subtitle ?? string.Empty;
|
||||||
|
ProviderId = providerId ?? string.Empty;
|
||||||
|
Id = string.IsNullOrEmpty(givenId) ? GenerateId() : givenId;
|
||||||
|
Command = new NoOpCommand() { Id = Id };
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string ProviderId { get; }
|
||||||
|
|
||||||
|
private string GenerateId()
|
||||||
|
{
|
||||||
|
var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0);
|
||||||
|
return $"{ProviderId}{result}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IPrecomputedFuzzyMatcher CreateMatcher() =>
|
||||||
|
new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
|
||||||
|
|
||||||
|
private static RecentCommandsManager EmptyHistory() => new();
|
||||||
|
|
||||||
|
// A representative slice of the main page: installed apps + top-level commands with
|
||||||
|
// realistic titles, subtitles and shared prefixes/acronyms. Deliberately includes the
|
||||||
|
// "confusable" clusters users complain about (Calc*, Visual Studio *, Command Prompt vs
|
||||||
|
// Control Panel) so the ordering constraints below have real competition to beat.
|
||||||
|
private static List<ListItemMock> Fixture() => new()
|
||||||
|
{
|
||||||
|
new("Command Prompt", "Run the classic command interpreter"),
|
||||||
|
new("Control Panel", "Adjust your computer's settings"),
|
||||||
|
new("Calculator", "Perform calculations"),
|
||||||
|
new("Calendar", "View your schedule"),
|
||||||
|
new("Visual Studio Code", "Code editing. Redefined."),
|
||||||
|
new("Visual Studio 2022", "Full-featured IDE"),
|
||||||
|
new("Windows Settings", "Change PC settings"),
|
||||||
|
new("Windows Terminal", "Modern terminal for command-line tools"),
|
||||||
|
new("Task Manager", "Monitor apps and processes"),
|
||||||
|
new("Notepad", "A simple text editor"),
|
||||||
|
new("Microsoft Edge", "Browse the web"),
|
||||||
|
new("Paint", "Draw and edit images"),
|
||||||
|
new("Paint 3D", "Create in three dimensions"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scores every fixture item for a query through the real product scorer and returns the
|
||||||
|
// matched titles in the exact order the product would render them: positive scores only,
|
||||||
|
// sorted descending. Mirrors InternalListHelpers.FilterListWithScores and the existing
|
||||||
|
// RecentCommandsTests.GetMatches helper.
|
||||||
|
private static List<string> Rank(
|
||||||
|
string query,
|
||||||
|
IEnumerable<ListItemMock> items,
|
||||||
|
IRecentCommandsManager? history = null,
|
||||||
|
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
|
||||||
|
{
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var q = matcher.PrecomputeQuery(query);
|
||||||
|
history ??= EmptyHistory();
|
||||||
|
|
||||||
|
return items
|
||||||
|
.Select(item => (item.Title, Score: MainListPage.ScoreTopLevelItem(q, item, history, matcher, providerWeightLookup)))
|
||||||
|
.Where(x => x.Score > 0)
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.Select(x => x.Title)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertRank1(
|
||||||
|
string query,
|
||||||
|
string expectedTitle,
|
||||||
|
IRecentCommandsManager? history = null,
|
||||||
|
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
|
||||||
|
{
|
||||||
|
var ranked = Rank(query, Fixture(), history, providerWeightLookup);
|
||||||
|
Assert.IsTrue(ranked.Count > 0, $"Query '{query}' should return at least one match");
|
||||||
|
Assert.AreEqual(
|
||||||
|
expectedTitle,
|
||||||
|
ranked[0],
|
||||||
|
$"Query '{query}' should surface '{expectedTitle}' at rank 1. Actual order: [{string.Join(", ", ranked)}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertRanksAbove(
|
||||||
|
string query,
|
||||||
|
string higher,
|
||||||
|
string lower,
|
||||||
|
IRecentCommandsManager? history = null,
|
||||||
|
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
|
||||||
|
{
|
||||||
|
var ranked = Rank(query, Fixture(), history, providerWeightLookup);
|
||||||
|
var higherIndex = ranked.IndexOf(higher);
|
||||||
|
var lowerIndex = ranked.IndexOf(lower);
|
||||||
|
|
||||||
|
Assert.IsTrue(higherIndex >= 0, $"Query '{query}' should match '{higher}'. Actual order: [{string.Join(", ", ranked)}]");
|
||||||
|
Assert.IsTrue(lowerIndex >= 0, $"Query '{query}' should match '{lower}'. Actual order: [{string.Join(", ", ranked)}]");
|
||||||
|
Assert.IsTrue(
|
||||||
|
higherIndex < lowerIndex,
|
||||||
|
$"Query '{query}' should rank '{higher}' above '{lower}'. Actual order: [{string.Join(", ", ranked)}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// End-to-end cases: the tier ladder, exercised through the real scorer.
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_ExactTitleBeatsPrefix()
|
||||||
|
{
|
||||||
|
// "Paint" is an exact title; "Paint 3D" only has it as a prefix. Exact must win.
|
||||||
|
AssertRank1("paint", "Paint");
|
||||||
|
AssertRanksAbove("paint", "Paint", "Paint 3D");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_PrefixBeatsWordBoundary()
|
||||||
|
{
|
||||||
|
// "co" is a title prefix of Command Prompt and Control Panel, but only a word-boundary
|
||||||
|
// match for "Code" inside Visual Studio Code. Prefix outranks word-boundary.
|
||||||
|
AssertRanksAbove("co", "Command Prompt", "Visual Studio Code");
|
||||||
|
AssertRanksAbove("co", "Control Panel", "Visual Studio Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_WordBoundaryBeatsFuzzy()
|
||||||
|
{
|
||||||
|
// "man" starts the word "Manager" in Task Manager (word-boundary), but is only a loose
|
||||||
|
// subsequence (m..a..n) of "Command Prompt" (fuzzy). Word-boundary must win.
|
||||||
|
AssertRank1("man", "Task Manager");
|
||||||
|
AssertRanksAbove("man", "Task Manager", "Command Prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_AcronymSurfacesTheRightApp()
|
||||||
|
{
|
||||||
|
// "vsc" is the acronym of Visual Studio Code (V-S-C); Visual Studio 2022 (V-S-2) is not
|
||||||
|
// a match. The acronym should surface the obviously-right app at rank 1.
|
||||||
|
AssertRank1("vsc", "Visual Studio Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_ComplaintCase_SingleLetterSurfacesFrecentApp()
|
||||||
|
{
|
||||||
|
// "c" prefixes several apps (Calculator, Calendar, Command Prompt, Control Panel). With
|
||||||
|
// no signal they tie; a user who keeps opening Calculator should see it at rank 1. This
|
||||||
|
// is the canonical "the thing I want is buried" complaint, fixed by within-tier frecency.
|
||||||
|
var history = EmptyHistory();
|
||||||
|
var calculatorId = Fixture().First(i => i.Title == "Calculator").Id;
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
history = history.WithHistoryItem(calculatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
AssertRank1("c", "Calculator", history);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_ComplaintCase_CodeSurfacesVsCode()
|
||||||
|
{
|
||||||
|
// Typing "code" should put Visual Studio Code first (word-boundary on "Code").
|
||||||
|
AssertRank1("code", "Visual Studio Code");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_ComplaintCase_SetSurfacesSettings()
|
||||||
|
{
|
||||||
|
// Typing "set" should put Windows Settings first (word-boundary on "Settings").
|
||||||
|
AssertRank1("set", "Windows Settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_FrecencyReordersWithinTierOnly()
|
||||||
|
{
|
||||||
|
// Heavy use of Visual Studio Code (a word-boundary match for "co") must NOT lift it over
|
||||||
|
// Command Prompt / Control Panel, which are prefix matches a whole tier above it.
|
||||||
|
// Frecency reorders within a tier; it can never cross a tier boundary.
|
||||||
|
var history = EmptyHistory();
|
||||||
|
var vsCodeId = Fixture().First(i => i.Title == "Visual Studio Code").Id;
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
history = history.WithHistoryItem(vsCodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
AssertRanksAbove("co", "Command Prompt", "Visual Studio Code", history);
|
||||||
|
AssertRanksAbove("co", "Control Panel", "Visual Studio Code", history);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_FrecencyBreaksTieWithinTier()
|
||||||
|
{
|
||||||
|
// "vs" is an acronym match for both Visual Studio Code and Visual Studio 2022 (same
|
||||||
|
// tier). With no history they tie; the recently/repeatedly used one should climb to the
|
||||||
|
// top of the tier.
|
||||||
|
var fixture = Fixture();
|
||||||
|
var vs2022Id = fixture.First(i => i.Title == "Visual Studio 2022").Id;
|
||||||
|
|
||||||
|
var history = EmptyHistory();
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
history = history.WithHistoryItem(vs2022Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
AssertRanksAbove("vs", "Visual Studio 2022", "Visual Studio Code", history);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_ProviderHigherBreaksAnExactTie()
|
||||||
|
{
|
||||||
|
// Two providers surface an identically-titled "Settings" command. Everything else being
|
||||||
|
// equal (same tier, same lexical quality, no frecency), a provider marked Higher should
|
||||||
|
// sort above the Normal one. Provider weight is a within-tier nudge for near-ties only.
|
||||||
|
var alpha = new ListItemMock("Settings", "From provider Alpha", providerId: "alpha");
|
||||||
|
var bravo = new ListItemMock("Settings", "From provider Bravo", providerId: "bravo");
|
||||||
|
var items = new List<ListItemMock> { alpha, bravo };
|
||||||
|
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var q = matcher.PrecomputeQuery("Settings");
|
||||||
|
var history = EmptyHistory();
|
||||||
|
|
||||||
|
// Baseline: with no provider weighting the two tie exactly.
|
||||||
|
var baseAlpha = MainListPage.ScoreTopLevelItem(q, alpha, history, matcher);
|
||||||
|
var baseBravo = MainListPage.ScoreTopLevelItem(q, bravo, history, matcher);
|
||||||
|
Assert.AreEqual(baseAlpha, baseBravo, "The two identically-titled items should tie before provider weighting");
|
||||||
|
|
||||||
|
Func<IListItem, ProviderSearchWeight> lookup = item =>
|
||||||
|
item is ListItemMock m && m.ProviderId == "bravo"
|
||||||
|
? ProviderSearchWeight.Higher
|
||||||
|
: ProviderSearchWeight.Normal;
|
||||||
|
|
||||||
|
var ranked = items
|
||||||
|
.Select(item => (item.ProviderId, Score: MainListPage.ScoreTopLevelItem(q, item, history, matcher, lookup)))
|
||||||
|
.OrderByDescending(x => x.Score)
|
||||||
|
.Select(x => x.ProviderId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.AreEqual("bravo", ranked[0], "The Higher-weighted provider should win an otherwise exact tie");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void EndToEnd_ProviderWeightCannotCrossTierBoundary()
|
||||||
|
{
|
||||||
|
// Even marked Higher, a word-boundary match (Visual Studio Code for "co") must stay
|
||||||
|
// below a prefix match (Command Prompt). Provider weight is clamped within a tier.
|
||||||
|
Func<IListItem, ProviderSearchWeight> boostVsCode = item =>
|
||||||
|
item is ListItemMock m && m.Title == "Visual Studio Code"
|
||||||
|
? ProviderSearchWeight.Higher
|
||||||
|
: ProviderSearchWeight.Normal;
|
||||||
|
|
||||||
|
AssertRanksAbove("co", "Command Prompt", "Visual Studio Code", providerWeightLookup: boostVsCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// 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.Linq;
|
||||||
|
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.VisualStudio.TestTools.UnitTesting;
|
||||||
|
using static Microsoft.CmdPal.UI.ViewModels.UnitTests.ScoringTestCatalog;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Guardrail for the throughput work: moving scoring off the TopLevelCommands lock and
|
||||||
|
/// parallelizing the apps pass may change how fast and where the settled list is computed, never
|
||||||
|
/// what it is. Across a synthetic catalog and several queries (the 1-char pathological case, the
|
||||||
|
/// extend chain, and the retype rebuild) the parallel scorer has to match the sequential one item
|
||||||
|
/// for item and score for score.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed partial class ScoringParallelEquivalenceTests
|
||||||
|
{
|
||||||
|
// Big enough that the parallel path is actually taken across multiple partitions, small enough
|
||||||
|
// to stay fast on CI.
|
||||||
|
private const int AppCount = 4000;
|
||||||
|
private const int CommandCount = 300;
|
||||||
|
private const int HistorySeedCount = 250;
|
||||||
|
|
||||||
|
// "c" is the pathological 1-char case, the "ca"/"cal"/"calc" chain is the extend path, and the
|
||||||
|
// acronym and multi-word cases stress the tier classifier.
|
||||||
|
private static readonly string[] Queries =
|
||||||
|
["c", "ca", "cal", "calc", "vs", "vsc", "vs code", "term", "set", "e"];
|
||||||
|
|
||||||
|
public TestContext TestContext { get; set; } = null!;
|
||||||
|
|
||||||
|
private static ScoringFunction<IListItem> BuildScoringFunction(
|
||||||
|
IRecentCommandsManager history,
|
||||||
|
IPrecomputedFuzzyMatcher matcher)
|
||||||
|
=> (in FuzzyQuery query, IListItem item) =>
|
||||||
|
MainListPage.ScoreTopLevelItem(query, item, history, matcher, null);
|
||||||
|
|
||||||
|
private static void AssertOrderedResultsIdentical(
|
||||||
|
string context,
|
||||||
|
RoScored<IListItem>[] reference,
|
||||||
|
RoScored<IListItem>[] candidate)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(reference.Length, candidate.Length, $"[{context}] result count must match the sequential reference.");
|
||||||
|
|
||||||
|
for (var i = 0; i < reference.Length; i++)
|
||||||
|
{
|
||||||
|
// Same packed score at the same index.
|
||||||
|
Assert.AreEqual(
|
||||||
|
reference[i].Score,
|
||||||
|
candidate[i].Score,
|
||||||
|
$"[{context}] score at index {i} must match the sequential reference.");
|
||||||
|
|
||||||
|
// Same item reference at the same index, which proves the order matches including
|
||||||
|
// tie-breaks, not just that the same scores turn up.
|
||||||
|
Assert.AreSame(
|
||||||
|
reference[i].Item,
|
||||||
|
candidate[i].Item,
|
||||||
|
$"[{context}] item at index {i} must be the exact same instance as the sequential reference.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The rebuild path: a fresh query scored against the whole catalog, where the parallel apps
|
||||||
|
/// pass has to match the sequential reference exactly.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ParallelScoring_FullCatalog_MatchesSequentialForEveryQuery()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
var source = apps.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
// Mirror the product: build the frecency index once before the parallel pass reads it.
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
foreach (var raw in Queries)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
|
||||||
|
var sequential = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
|
||||||
|
var parallel = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
|
||||||
|
|
||||||
|
TestContext.WriteLine($"query '{raw}': {sequential.Length} matches (sequential) vs {parallel.Length} (parallel).");
|
||||||
|
AssertOrderedResultsIdentical($"full '{raw}'", sequential, parallel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The extend path: score the whole catalog for a 1-char query, keep the matched subset in the
|
||||||
|
/// order it came back, then re-score that subset for the extending query.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ParallelScoring_ExtendPath_MatchesSequentialOverRetainedSubset()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
var source = apps.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
// Each step narrows the previous result.
|
||||||
|
var chain = new[] { "c", "ca", "cal", "calc" };
|
||||||
|
|
||||||
|
var retained = source;
|
||||||
|
for (var step = 1; step < chain.Length; step++)
|
||||||
|
{
|
||||||
|
// This is exactly what the product feeds the next keystroke: the previous result's
|
||||||
|
// items, in the previous result's order.
|
||||||
|
var prevQuery = matcher.PrecomputeQuery(chain[step - 1]);
|
||||||
|
var prev = InternalListHelpers.FilterListWithScores(retained, prevQuery, scoringFn);
|
||||||
|
retained = prev.Select(s => s.Item).ToArray();
|
||||||
|
|
||||||
|
var query = matcher.PrecomputeQuery(chain[step]);
|
||||||
|
var sequential = InternalListHelpers.FilterListWithScores(retained, query, scoringFn);
|
||||||
|
var parallel = InternalListHelpers.FilterListWithScoresParallel(retained, query, scoringFn);
|
||||||
|
|
||||||
|
TestContext.WriteLine($"extend '{chain[step - 1]}' -> '{chain[step]}': retained {retained.Length}, kept {sequential.Length}.");
|
||||||
|
AssertOrderedResultsIdentical($"extend '{chain[step - 1]}'->'{chain[step]}'", sequential, parallel);
|
||||||
|
|
||||||
|
Assert.IsTrue(sequential.Length <= retained.Length, "Extending a query cannot add matches beyond the retained set.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The retype path: a query that doesn't extend the previous one forces a full rebuild, so
|
||||||
|
/// check a run of unrelated queries scored fresh each time.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ParallelScoring_RetypeRebuild_MatchesSequential()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
var source = apps.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
// Unrelated queries (each a fresh rebuild, never an extend of the last).
|
||||||
|
foreach (var raw in new[] { "calc", "term", "vs code", "settings", "e" })
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
var sequential = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
|
||||||
|
var parallel = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
|
||||||
|
|
||||||
|
AssertOrderedResultsIdentical($"retype '{raw}'", sequential, parallel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Commands (hundreds) stay serial, so the parallel entry point has to fall back below its
|
||||||
|
/// threshold and still match the sequential result.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ParallelScoring_Commands_MatchesSequential()
|
||||||
|
{
|
||||||
|
var commands = BuildCatalog(CommandCount, "cmd");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(commands, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
var source = commands.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
foreach (var raw in Queries)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
var sequential = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
|
||||||
|
var parallel = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
|
||||||
|
|
||||||
|
AssertOrderedResultsIdentical($"commands '{raw}'", sequential, parallel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Running the parallel scorer over the same catalog and query repeatedly gives the same
|
||||||
|
/// ordered result every time, whatever the thread scheduling does.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void ParallelScoring_IsDeterministicAcrossRuns()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
var source = apps.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
var query = matcher.PrecomputeQuery("c");
|
||||||
|
var first = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
|
||||||
|
|
||||||
|
for (var run = 0; run < 8; run++)
|
||||||
|
{
|
||||||
|
var again = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
|
||||||
|
AssertOrderedResultsIdentical($"determinism run {run}", first, again);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hot path snapshots the frecency manager, matcher, settings and one evaluation time per
|
||||||
|
/// query, and feeds the apps pass a single constant provider weight. This proves that captured
|
||||||
|
/// context lands on the same ordered result as the old per-item live reads.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void CapturedContext_ConstantWeightAndFixedNow_MatchesPerItemLiveRead()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var source = apps.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
// A non-default weight, so the value has to actually flow through to the packed score.
|
||||||
|
const ProviderSearchWeight weight = ProviderSearchWeight.Higher;
|
||||||
|
Func<IListItem, ProviderSearchWeight> perItemLookup = _ => weight;
|
||||||
|
Func<IListItem, ProviderSearchWeight> constantLookup = _ => weight;
|
||||||
|
|
||||||
|
// Captured once before the loop, exactly as the product captures scoringNow. The reference
|
||||||
|
// path below omits it, so it reads the current time per call.
|
||||||
|
var capturedNow = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
ScoringFunction<IListItem> liveReadScorer = (in FuzzyQuery query, IListItem item) =>
|
||||||
|
MainListPage.ScoreTopLevelItem(query, item, history, matcher, perItemLookup);
|
||||||
|
ScoringFunction<IListItem> capturedContextScorer = (in FuzzyQuery query, IListItem item) =>
|
||||||
|
MainListPage.ScoreTopLevelItem(query, item, history, matcher, constantLookup, capturedNow);
|
||||||
|
|
||||||
|
foreach (var raw in Queries)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
|
||||||
|
var reference = InternalListHelpers.FilterListWithScores(source, query, liveReadScorer);
|
||||||
|
var candidate = InternalListHelpers.FilterListWithScoresParallel(source, query, capturedContextScorer);
|
||||||
|
|
||||||
|
TestContext.WriteLine($"captured-context '{raw}': {reference.Length} matches (per-item live read) vs {candidate.Length} (constant weight + fixed now).");
|
||||||
|
AssertOrderedResultsIdentical($"captured '{raw}'", reference, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// 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 Microsoft.CmdPal.Common.Helpers;
|
||||||
|
using Microsoft.CmdPal.Common.Text;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.Commands;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
internal static partial class ScoringTestCatalog
|
||||||
|
{
|
||||||
|
internal sealed partial class CatalogItem : ListItem, IPrecomputedListItem
|
||||||
|
{
|
||||||
|
private FuzzyTargetCache _titleCache;
|
||||||
|
private FuzzyTargetCache _subtitleCache;
|
||||||
|
|
||||||
|
internal CatalogItem(string title, string subtitle, string id)
|
||||||
|
: base(new NoOpCommand() { Id = id })
|
||||||
|
{
|
||||||
|
Title = title;
|
||||||
|
Subtitle = subtitle;
|
||||||
|
Id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string Id { get; }
|
||||||
|
|
||||||
|
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher) => _titleCache.GetOrUpdate(matcher, Title);
|
||||||
|
|
||||||
|
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher) => _subtitleCache.GetOrUpdate(matcher, Subtitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] Nouns =
|
||||||
|
[
|
||||||
|
"Calculator", "Calendar", "Camera", "Canvas", "Command", "Control", "Cloud", "Cast",
|
||||||
|
"Visual", "Studio", "Code", "Terminal", "Task", "Notepad", "Paint", "Photos", "Player",
|
||||||
|
"Panel", "Prompt", "Settings", "Store", "System", "Manager", "Monitor", "Editor", "Browser",
|
||||||
|
"Mail", "Maps", "Music", "Movies", "Network", "Office", "Onenote", "Outlook", "People",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] Qualifiers =
|
||||||
|
[
|
||||||
|
string.Empty, "Pro", "2022", "3D", "Preview", "X", "Lite", "Plus", "Home", "Enterprise",
|
||||||
|
"for Windows", "Insider", "Legacy", "New", "Classic",
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] SubtitleWords =
|
||||||
|
[
|
||||||
|
"Perform calculations and conversions", "View and manage your schedule", "Edit and refine images",
|
||||||
|
"Modern terminal for command-line tools", "Full-featured integrated development environment",
|
||||||
|
"Browse the web quickly and securely", "Adjust your computer settings", "Monitor apps and processes",
|
||||||
|
"A simple and fast text editor", "Play and organize your media library",
|
||||||
|
];
|
||||||
|
|
||||||
|
internal static IPrecomputedFuzzyMatcher CreateMatcher() => new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
|
||||||
|
|
||||||
|
internal static CatalogItem[] BuildCatalog(int count, string idPrefix)
|
||||||
|
{
|
||||||
|
var items = new CatalogItem[count];
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var noun = Nouns[i % Nouns.Length];
|
||||||
|
var qualifier = Qualifiers[(i / Nouns.Length) % Qualifiers.Length];
|
||||||
|
var title = string.IsNullOrEmpty(qualifier) ? noun : $"{noun} {qualifier}";
|
||||||
|
|
||||||
|
if (i >= Nouns.Length * Qualifiers.Length)
|
||||||
|
{
|
||||||
|
title = $"{title} {i}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitle = SubtitleWords[i % SubtitleWords.Length];
|
||||||
|
items[i] = new CatalogItem(title, subtitle, $"{idPrefix}.{i}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static RecentCommandsManager SeedHistory(CatalogItem[] apps, int seedCount)
|
||||||
|
{
|
||||||
|
var history = new RecentCommandsManager();
|
||||||
|
var n = Math.Min(seedCount, apps.Length);
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
{
|
||||||
|
var idx = (i * 7) % apps.Length;
|
||||||
|
history = history.WithHistoryItem(apps[idx].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
// 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 System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
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.VisualStudio.TestTools.UnitTesting;
|
||||||
|
using static Microsoft.CmdPal.UI.ViewModels.UnitTests.ScoringTestCatalog;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Times the real per-keystroke scoring passes over a synthetic catalog and attributes the cost
|
||||||
|
/// across apps-enumeration, command scoring, app scoring (the dominant pass), and fallback
|
||||||
|
/// fold-in, plus a per-item split of <see cref="MainListPage.ScoreTopLevelItem"/> into fuzzy DP
|
||||||
|
/// vs tier classification vs frecency. Timings go to <see cref="TestContext"/> and the assertions
|
||||||
|
/// only lock structural facts, so nothing here flakes on a wall clock.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed partial class ScoringThroughputHarnessTests
|
||||||
|
{
|
||||||
|
// Sized to mirror a heavy-but-realistic machine, and small enough that the whole harness runs
|
||||||
|
// in a couple of seconds on CI.
|
||||||
|
private const int AppCount = 3000;
|
||||||
|
private const int CommandCount = 300;
|
||||||
|
private const int GlobalFallbackCount = 5;
|
||||||
|
private const int PinnedAppCount = 20;
|
||||||
|
|
||||||
|
// Seed enough history that frecency lookups actually hit, so we measure the hit path too.
|
||||||
|
private const int HistorySeedCount = 200;
|
||||||
|
|
||||||
|
// Report-only counts: warmups prime the JIT and target caches, measured runs get averaged.
|
||||||
|
private const int WarmupIterations = 3;
|
||||||
|
private const int MeasuredIterations = 10;
|
||||||
|
|
||||||
|
// "c" is the pathological 1-char case that matches nearly every app, the rest narrow a real
|
||||||
|
// prefix, and "vsc"/"vs code" hit the acronym and word-boundary paths.
|
||||||
|
private static readonly string[] Queries = ["c", "ca", "cal", "calc", "vsc", "vs code"];
|
||||||
|
|
||||||
|
public TestContext TestContext { get; set; } = null!;
|
||||||
|
|
||||||
|
private static ScoringFunction<IListItem> BuildScoringFunction(
|
||||||
|
IRecentCommandsManager history,
|
||||||
|
IPrecomputedFuzzyMatcher matcher,
|
||||||
|
Func<IListItem, ProviderSearchWeight>? providerWeightLookup = null)
|
||||||
|
=> (in FuzzyQuery query, IListItem item) =>
|
||||||
|
MainListPage.ScoreTopLevelItem(query, item, history, matcher, providerWeightLookup);
|
||||||
|
|
||||||
|
// Averaged elapsed milliseconds after a warmup, report-only and never asserted against a
|
||||||
|
// threshold.
|
||||||
|
private static double TimeAverageMs(Action action)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < WarmupIterations; i++)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
for (var i = 0; i < MeasuredIterations; i++)
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
return sw.Elapsed.TotalMilliseconds / MeasuredIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attributes a full rebuild keystroke (empty to query, the worst case that scores the whole
|
||||||
|
/// catalog) across the four passes. Asserts only that the app pass outweighs the command pass
|
||||||
|
/// and that scoring is deterministic.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void FullKeystroke_AttributesCostAcrossBuckets()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var commands = BuildCatalog(CommandCount, "cmd");
|
||||||
|
var globalFallbacks = BuildCatalog(GlobalFallbackCount, "gfb").Cast<IListItem>().ToList();
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
|
||||||
|
// Simulate the pinned-app removal the product does on the rebuild path.
|
||||||
|
var pinnedIds = new HashSet<string>(apps.Take(PinnedAppCount).Select(a => a.Id));
|
||||||
|
|
||||||
|
TestContext.WriteLine($"Catalog: {AppCount} apps, {CommandCount} commands, {GlobalFallbackCount} global fallbacks, {HistorySeedCount} history seeds.");
|
||||||
|
TestContext.WriteLine($"Iterations: {WarmupIterations} warmup + {MeasuredIterations} measured (averaged).");
|
||||||
|
TestContext.WriteLine("query | appsEnum ms | cmdScore ms | appScore ms | fallback ms | TOTAL ms | cmdMatches | appMatches");
|
||||||
|
|
||||||
|
foreach (var raw in Queries)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
|
||||||
|
var enumMs = TimeAverageMs(() =>
|
||||||
|
{
|
||||||
|
// Mirrors the product's GetItems().Cast().ToList() plus the pinned filter.
|
||||||
|
var materialized = apps.ToList();
|
||||||
|
_ = materialized.Where(a => !pinnedIds.Contains(a.Id)).ToList();
|
||||||
|
});
|
||||||
|
|
||||||
|
RoScored<IListItem>[] cmdScored = [];
|
||||||
|
var cmdMs = TimeAverageMs(() =>
|
||||||
|
{
|
||||||
|
cmdScored = InternalListHelpers.FilterListWithScores(commands.Cast<IListItem>(), query, scoringFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
RoScored<IListItem>[] appScored = [];
|
||||||
|
var appMs = TimeAverageMs(() =>
|
||||||
|
{
|
||||||
|
appScored = InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>(), query, scoringFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
var fallbackMs = TimeAverageMs(() =>
|
||||||
|
{
|
||||||
|
_ = MainListPage.ScoreDeferredFallbacks(globalFallbacks, query, scoringFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
var total = enumMs + cmdMs + appMs + fallbackMs;
|
||||||
|
TestContext.WriteLine(
|
||||||
|
$"{raw,-8}| {enumMs,10:F3} | {cmdMs,10:F3} | {appMs,10:F3} | {fallbackMs,10:F3} | {total,8:F3} | {cmdScored.Length,10} | {appScored.Length,10}");
|
||||||
|
|
||||||
|
// The app pass weighs thousands of items against the command pass's hundreds, which is
|
||||||
|
// why it dominates the keystroke cost.
|
||||||
|
Assert.IsTrue(apps.Length > commands.Length, "Apps must outnumber commands in the catalog.");
|
||||||
|
|
||||||
|
// Re-scoring the same catalog with the same query yields the same result set.
|
||||||
|
var appScoredAgain = InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>(), query, scoringFn);
|
||||||
|
Assert.AreEqual(appScored.Length, appScoredAgain.Length, $"App scoring must be deterministic for query '{raw}'.");
|
||||||
|
for (var i = 0; i < Math.Min(10, appScored.Length); i++)
|
||||||
|
{
|
||||||
|
Assert.AreEqual(appScored[i].Score, appScoredAgain[i].Score, $"Top-10 scores must be stable for query '{raw}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Characterizes why the settle time spikes: a 1-char query retains a large slice of the
|
||||||
|
/// catalog, so the next few keystrokes still re-score a big set before it narrows.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void OneCharQuery_RetainsLargeMatchSet_DrivesIncrementalCost()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
|
||||||
|
var oneChar = matcher.PrecomputeQuery("c");
|
||||||
|
var firstMatches = InternalListHelpers.FilterListWithScores(apps.Cast<IListItem>(), oneChar, scoringFn);
|
||||||
|
|
||||||
|
// Extending to "ca" re-scores only the retained subset, but that subset is still large,
|
||||||
|
// which is why the spike carries across frames.
|
||||||
|
var retained = firstMatches.Select(s => s.Item).ToList();
|
||||||
|
var twoChar = matcher.PrecomputeQuery("ca");
|
||||||
|
var secondMatches = InternalListHelpers.FilterListWithScores(retained, twoChar, scoringFn);
|
||||||
|
|
||||||
|
var firstFraction = (double)firstMatches.Length / apps.Length;
|
||||||
|
TestContext.WriteLine($"1-char 'c' matches {firstMatches.Length}/{apps.Length} apps ({firstFraction:P1}); extending to 'ca' re-scores {retained.Count} and keeps {secondMatches.Length}.");
|
||||||
|
|
||||||
|
// A narrower query can only keep a subset of the wider query's matches.
|
||||||
|
Assert.IsTrue(secondMatches.Length <= retained.Count, "Extending a query cannot add matches beyond the retained set.");
|
||||||
|
Assert.IsTrue(firstMatches.Length > 0, "The 1-char query should match a non-trivial set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a single <see cref="MainListPage.ScoreTopLevelItem"/> into fuzzy DP scoring, tier
|
||||||
|
/// classification, and frecency lookup so the overhaul's added cost is visible. Asserts only
|
||||||
|
/// the direction of the extension-score delta, which holds regardless of the machine.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void PerItemScore_SubAttribution_DpVsTierVsFrecency()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
|
||||||
|
// Precompute targets once, like the live cached path, so this measures matcher.Score and
|
||||||
|
// not target construction.
|
||||||
|
var titleTargets = apps.Select(a => a.GetTitleTarget(matcher)).ToArray();
|
||||||
|
var subtitleTargets = apps.Select(a => a.GetSubtitleTarget(matcher)).ToArray();
|
||||||
|
var extensionTargets = apps.Select(a => matcher.PrecomputeTarget($"{a.Title} Extension")).ToArray();
|
||||||
|
var ids = apps.Select(a => a.Id).ToArray();
|
||||||
|
|
||||||
|
TestContext.WriteLine("Per-item sub-attribution (nanoseconds/item, averaged over the app catalog):");
|
||||||
|
TestContext.WriteLine("query | full score | 2 DP | 3 DP | extDelta | classifyTier | wordBoundary | frecency");
|
||||||
|
|
||||||
|
foreach (var raw in Queries)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
|
||||||
|
var fullNs = PerItemNs(() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < apps.Length; i++)
|
||||||
|
{
|
||||||
|
_ = MainListPage.ScoreTopLevelItem(query, apps[i], history, matcher, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var twoDpNs = PerItemNs(() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < apps.Length; i++)
|
||||||
|
{
|
||||||
|
_ = matcher.Score(query, titleTargets[i]) + matcher.Score(query, subtitleTargets[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var threeDpNs = PerItemNs(() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < apps.Length; i++)
|
||||||
|
{
|
||||||
|
_ = matcher.Score(query, titleTargets[i]) + matcher.Score(query, subtitleTargets[i]) + matcher.Score(query, extensionTargets[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var classifyNs = PerItemNs(() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < apps.Length; i++)
|
||||||
|
{
|
||||||
|
_ = MainListRanker.ClassifyTier(query.Original, apps[i].Title, false, false, false, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var wordBoundaryNs = PerItemNs(() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < apps.Length; i++)
|
||||||
|
{
|
||||||
|
_ = MainListRanker.MatchesWordBoundaryOrAcronym(apps[i].Title, query.Original.AsSpan());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var frecencyNs = PerItemNs(() =>
|
||||||
|
{
|
||||||
|
for (var i = 0; i < apps.Length; i++)
|
||||||
|
{
|
||||||
|
_ = history.GetCommandHistoryWeight(ids[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var extDelta = threeDpNs - twoDpNs;
|
||||||
|
TestContext.WriteLine(
|
||||||
|
$"{raw,-8}| {fullNs,10:F1} | {twoDpNs,6:F1} | {threeDpNs,6:F1} | {extDelta,8:F1} | {classifyNs,12:F1} | {wordBoundaryNs,12:F1} | {frecencyNs,8:F1}");
|
||||||
|
|
||||||
|
// Adding a third DP score can't make the measurement cheaper.
|
||||||
|
Assert.IsTrue(threeDpNs >= twoDpNs * 0.5, "Three DP scores should not be dramatically cheaper than two; extension scoring is real added work.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Times the dominant apps pass serial versus parallel and reports the speedup per query.
|
||||||
|
/// Report-only: it asserts the two paths return the same match count, never a wall-clock
|
||||||
|
/// threshold.
|
||||||
|
/// </summary>
|
||||||
|
[TestMethod]
|
||||||
|
public void AppScoring_BeforeAfter_SerialVsParallelThroughput()
|
||||||
|
{
|
||||||
|
var apps = BuildCatalog(AppCount, "app");
|
||||||
|
var matcher = CreateMatcher();
|
||||||
|
var history = SeedHistory(apps, HistorySeedCount);
|
||||||
|
var scoringFn = BuildScoringFunction(history, matcher);
|
||||||
|
var source = apps.Cast<IListItem>().ToArray();
|
||||||
|
|
||||||
|
// Build the frecency index once, single-threaded, before the parallel pass reads it.
|
||||||
|
history.PrewarmIndex();
|
||||||
|
|
||||||
|
TestContext.WriteLine($"CPU count: {Environment.ProcessorCount}. Catalog: {AppCount} apps.");
|
||||||
|
TestContext.WriteLine("query | serial ms (before) | parallel ms (after) | speedup | matches");
|
||||||
|
|
||||||
|
foreach (var raw in Queries)
|
||||||
|
{
|
||||||
|
var query = matcher.PrecomputeQuery(raw);
|
||||||
|
|
||||||
|
RoScored<IListItem>[] serialResult = [];
|
||||||
|
var serialMs = TimeAverageMs(() =>
|
||||||
|
{
|
||||||
|
serialResult = InternalListHelpers.FilterListWithScores(source, query, scoringFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
RoScored<IListItem>[] parallelResult = [];
|
||||||
|
var parallelMs = TimeAverageMs(() =>
|
||||||
|
{
|
||||||
|
parallelResult = InternalListHelpers.FilterListWithScoresParallel(source, query, scoringFn);
|
||||||
|
});
|
||||||
|
|
||||||
|
var speedup = parallelMs > 0 ? serialMs / parallelMs : 0.0;
|
||||||
|
TestContext.WriteLine(
|
||||||
|
$"{raw,-8}| {serialMs,17:F3} | {parallelMs,18:F3} | {speedup,6:F2}x | {serialResult.Length,7}");
|
||||||
|
|
||||||
|
// The parallel path returns the same match count, on any machine.
|
||||||
|
Assert.AreEqual(serialResult.Length, parallelResult.Length, $"Match count must match for query '{raw}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Averaged per-item nanoseconds for a loop that internally iterates the whole app catalog once.
|
||||||
|
private static double PerItemNs(Action loopOverCatalog)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < WarmupIterations; i++)
|
||||||
|
{
|
||||||
|
loopOverCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
for (var i = 0; i < MeasuredIterations; i++)
|
||||||
|
{
|
||||||
|
loopOverCatalog();
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
var totalItems = (double)MeasuredIterations * AppCount;
|
||||||
|
return sw.Elapsed.TotalMilliseconds * 1_000_000.0 / totalItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// 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 System.Linq;
|
||||||
|
using Microsoft.CmdPal.Common.Helpers;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||||
|
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||||
|
using Microsoft.CommandPalette.Extensions;
|
||||||
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||||
|
using Windows.Foundation;
|
||||||
|
|
||||||
|
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the opt-in, privacy-safe main-page search telemetry payload builders. These assert
|
||||||
|
/// that only non-identifying aggregates are captured (query LENGTH, result count, selected rank,
|
||||||
|
/// ranker tier) and never the raw query text or item content. The actual telemetry sink is not
|
||||||
|
/// exercised - only the payload-building logic.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public partial class SearchTelemetryTests
|
||||||
|
{
|
||||||
|
private sealed partial class MockListItem : 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 => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public ITag[] Tags => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public string TextToSuggest => throw new NotImplementedException();
|
||||||
|
|
||||||
|
public IContextItem[] MoreCommands => throw new NotImplementedException();
|
||||||
|
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RoScored<IListItem> Scored(IListItem item, int score) => new(item, score);
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SearchResultsMessage_CapturesQueryLengthNotText()
|
||||||
|
{
|
||||||
|
const string query = "hello world";
|
||||||
|
var message = MainListPageSearchTelemetry.BuildSearchResultsMessage(query, resultCount: 4, latencyMs: 12);
|
||||||
|
|
||||||
|
Assert.AreEqual(query.Length, message.QueryLength);
|
||||||
|
Assert.AreEqual(4, message.ResultCount);
|
||||||
|
Assert.IsFalse(message.NoResults);
|
||||||
|
Assert.AreEqual(12UL, message.LatencyMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SearchResultsMessage_SetsNoResultsFlagWhenCountIsZero()
|
||||||
|
{
|
||||||
|
var noResults = MainListPageSearchTelemetry.BuildSearchResultsMessage("abc", resultCount: 0, latencyMs: 5);
|
||||||
|
Assert.IsTrue(noResults.NoResults);
|
||||||
|
Assert.AreEqual(0, noResults.ResultCount);
|
||||||
|
|
||||||
|
var hasResults = MainListPageSearchTelemetry.BuildSearchResultsMessage("abc", resultCount: 3, latencyMs: 5);
|
||||||
|
Assert.IsFalse(hasResults.NoResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SearchResultsMessage_ClampsNegativeInputs()
|
||||||
|
{
|
||||||
|
var message = MainListPageSearchTelemetry.BuildSearchResultsMessage(queryLength: -3, resultCount: -1, latencyMs: -100);
|
||||||
|
|
||||||
|
Assert.AreEqual(0, message.QueryLength);
|
||||||
|
Assert.AreEqual(0, message.ResultCount);
|
||||||
|
Assert.IsTrue(message.NoResults);
|
||||||
|
Assert.AreEqual(0UL, message.LatencyMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SearchResultsMessage_HasNoStringFields()
|
||||||
|
{
|
||||||
|
// A raw query string can only be captured through a string member. Assert there is none,
|
||||||
|
// so the payload provably cannot carry the query text.
|
||||||
|
var stringProperties = typeof(TelemetrySearchResultsMessage)
|
||||||
|
.GetProperties()
|
||||||
|
.Where(p => p.PropertyType == typeof(string))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, stringProperties.Count, "Search results telemetry must not carry any string (potential query text).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SelectedMessage_CapturesQueryLengthIndexAndTier()
|
||||||
|
{
|
||||||
|
const string query = "code";
|
||||||
|
var message = MainListPageSearchTelemetry.BuildSearchSelectedMessage(query, selectedIndex: 2, selectedTier: RankTier.Prefix);
|
||||||
|
|
||||||
|
Assert.AreEqual(query.Length, message.QueryLength);
|
||||||
|
Assert.AreEqual(2, message.SelectedIndex);
|
||||||
|
Assert.AreEqual(RankTier.Prefix, message.SelectedTier);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void SelectedMessage_HasNoStringFields()
|
||||||
|
{
|
||||||
|
var stringProperties = typeof(TelemetrySearchResultSelectedMessage)
|
||||||
|
.GetProperties()
|
||||||
|
.Where(p => p.PropertyType == typeof(string))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.AreEqual(0, stringProperties.Count, "Selection telemetry must not carry any string (potential query text or item title).");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ResolveSelectedTier_DerivesTierFromPackedScore()
|
||||||
|
{
|
||||||
|
var exact = new MockListItem { Title = "Visual Studio" };
|
||||||
|
var fuzzy = new MockListItem { Title = "Notepad" };
|
||||||
|
|
||||||
|
var packed = new List<RoScored<IListItem>>
|
||||||
|
{
|
||||||
|
Scored(exact, MainListRanker.Pack(RankTier.ExactTitle, withinTierScore: 500)),
|
||||||
|
Scored(fuzzy, MainListRanker.Pack(RankTier.Fuzzy, withinTierScore: 10)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.AreEqual(RankTier.ExactTitle, MainListPageSearchTelemetry.ResolveSelectedTier(exact, packed, fallbackResults: null));
|
||||||
|
Assert.AreEqual(RankTier.Fuzzy, MainListPageSearchTelemetry.ResolveSelectedTier(fuzzy, packed, fallbackResults: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ResolveSelectedTier_ReportsFallbackFloorForCommonFallbacks()
|
||||||
|
{
|
||||||
|
var fallback = new MockListItem { Title = "Search the web" };
|
||||||
|
|
||||||
|
// Common fallbacks carry small rank-based (non-packed) scores. They must be reported at the
|
||||||
|
// fallback floor rather than being decoded as a packed tier.
|
||||||
|
var fallbacks = new List<RoScored<IListItem>> { Scored(fallback, 3) };
|
||||||
|
|
||||||
|
Assert.AreEqual(RankTier.FallbackFloor, MainListPageSearchTelemetry.ResolveSelectedTier(fallback, packedResults: null, fallbacks));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ResolveSelectedTier_ReturnsNoneWhenItemNotFound()
|
||||||
|
{
|
||||||
|
var known = new MockListItem { Title = "Known" };
|
||||||
|
var unknown = new MockListItem { Title = "Unknown" };
|
||||||
|
|
||||||
|
var packed = new List<RoScored<IListItem>> { Scored(known, MainListRanker.Pack(RankTier.Prefix, 1)) };
|
||||||
|
|
||||||
|
Assert.AreEqual(RankTier.None, MainListPageSearchTelemetry.ResolveSelectedTier(unknown, packed, fallbackResults: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void ResolveVisibleIndex_SkipsSeparatorsAndReturnsVisibleRank()
|
||||||
|
{
|
||||||
|
var resultsSeparator = new Separator("Results");
|
||||||
|
var fallbacksSeparator = new Separator("Fallbacks");
|
||||||
|
|
||||||
|
var a = new MockListItem { Title = "A" };
|
||||||
|
var b = new MockListItem { Title = "B" };
|
||||||
|
var c = new MockListItem { Title = "C" };
|
||||||
|
var missing = new MockListItem { Title = "Missing" };
|
||||||
|
|
||||||
|
var rendered = new IListItem[] { resultsSeparator, a, b, fallbacksSeparator, c };
|
||||||
|
|
||||||
|
Assert.AreEqual(0, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, a, resultsSeparator, fallbacksSeparator));
|
||||||
|
Assert.AreEqual(1, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, b, resultsSeparator, fallbacksSeparator));
|
||||||
|
Assert.AreEqual(2, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, c, resultsSeparator, fallbacksSeparator));
|
||||||
|
Assert.AreEqual(-1, MainListPageSearchTelemetry.ResolveVisibleIndex(rendered, missing, resultsSeparator, fallbacksSeparator));
|
||||||
|
Assert.AreEqual(-1, MainListPageSearchTelemetry.ResolveVisibleIndex(null, a, resultsSeparator, fallbacksSeparator));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void TierOf_RoundTripsEveryPackedTier()
|
||||||
|
{
|
||||||
|
foreach (RankTier tier in Enum.GetValues<RankTier>())
|
||||||
|
{
|
||||||
|
if (tier == RankTier.None)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var packed = MainListRanker.Pack(tier, withinTierScore: 42);
|
||||||
|
Assert.AreEqual(tier, MainListRanker.TierOf(packed), $"TierOf should round-trip {tier}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user