[CmdPal] Opt-in, privacy-safe search telemetry

Add opt-in, privacy-safe main-page search telemetry that captures only

non-identifying aggregates via the existing PowerToys telemetry consent gate.

Query-settle event (CmdPal_SearchResults): query LENGTH, result count,

no-results flag, latency ms. Debounced so it fires once when a search

settles, not per keystroke.

Selection event (CmdPal_SearchResultSelected): query LENGTH, selected

visible rank, and ranker tier (via MainListRanker.TierOf). Fired on invoke.

Raw query text, titles, subtitles, paths, and app names are never logged.

Emission flows through PowerToysTelemetry.Log.WriteEvent (consent-gated)

and events are tagged PartA_PrivTags.ProductAndServiceUsage.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Michael Jolley
2026-07-08 02:41:36 -05:00
parent dc19b6123a
commit 96b9e35b07
7 changed files with 549 additions and 2 deletions

View File

@@ -89,6 +89,20 @@ public sealed partial class MainListPage : DynamicListPage,
private CancellationTokenSource? _cancellationTokenSource;
// Search telemetry. Emitted only when a search settles (trailing-edge debounce) so we never
// send an event on every keystroke, and only for non-identifying aggregates (query length,
// result count, latency) - never the raw query text. Selection telemetry is emitted from
// UpdateHistory. All emission is measured at boundaries, never inside the per-item scoring loop.
private static readonly TimeSpan SearchTelemetrySettleDelay = TimeSpan.FromMilliseconds(600);
private readonly ThrottledDebouncedAction _searchTelemetryDebounce;
private readonly Lock _searchTelemetryLock = new();
private (int QueryLength, int ResultCount, long LatencyMs) _pendingSearchTelemetry;
// 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.
private IReadOnlyList<IListItem>? _lastSearchViewItems;
private IReadOnlyList<RoScored<IListItem>>? _lastScoredGlobalFallbacks;
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
private DateTimeOffset _last = DateTimeOffset.UtcNow;
#endif
@@ -153,6 +167,8 @@ public sealed partial class MainListPage : DynamicListPage,
_fallbackUpdateManager = new FallbackUpdateManager(() => RequestRefresh(fullRefresh: false));
_searchTelemetryDebounce = new ThrottledDebouncedAction(EmitSearchResultsTelemetry, SearchTelemetrySettleDelay);
// The all apps page will kick off a BG thread to start loading apps.
// We just want to know when it is done.
var allApps = AllAppsCommandProvider.Page;
@@ -280,7 +296,7 @@ public sealed partial class MainListPage : DynamicListPage,
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
var result = MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
@@ -288,6 +304,14 @@ public sealed partial class MainListPage : DynamicListPage,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
// Snapshot the rendered order and the (packed-score) global fallbacks so selection
// telemetry can resolve an invoked item's rank and tier off the hot path. These are
// plain reference assignments - no extra allocation on the render path.
_lastSearchViewItems = result;
_lastScoredGlobalFallbacks = validScoredFallbacks;
return result;
}
// Scores the current global-fallback snapshot against its query using the supplied ranker,
@@ -512,6 +536,12 @@ public sealed partial class MainListPage : DynamicListPage,
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
// Drop any pending settled-search telemetry so a cleared query never emits.
_searchTelemetryDebounce.Cancel();
_lastSearchViewItems = null;
_lastScoredGlobalFallbacks = null;
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
@@ -654,6 +684,18 @@ public sealed partial class MainListPage : DynamicListPage,
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
#endif
// Queue a settled-search telemetry event. This measures the deterministic first-paint
// results (commands + capped apps) and the latency to produce them, at this boundary -
// never inside the per-item scoring loop. The event is debounced so it is emitted only
// when the query settles, not on every keystroke, and it carries the query LENGTH only.
if (isUserInput)
{
var deterministicResultCount = (_filteredItems?.Length ?? 0)
+ Math.Min(_filteredApps?.Length ?? 0, AppResultLimit);
QueueSearchResultsTelemetry(newSearch.Length, deterministicResultCount, stopwatch.ElapsedMilliseconds);
}
if (isUserInput)
{
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
@@ -813,6 +855,159 @@ public sealed partial class MainListPage : DynamicListPage,
{
RecentCommands = state.RecentCommands.WithHistoryItem(id),
});
EmitSelectionTelemetry(topLevelOrAppItem);
}
// 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.
private void EmitSelectionTelemetry(IListItem invoked)
{
var searchText = SearchText;
if (string.IsNullOrWhiteSpace(searchText))
{
return;
}
var index = ResolveVisibleIndex(_lastSearchViewItems, invoked, _resultsSeparator, _fallbacksSeparator);
if (index < 0)
{
return;
}
var packed = (_filteredItems ?? Enumerable.Empty<RoScored<IListItem>>())
.Concat(_filteredApps ?? Enumerable.Empty<RoScored<IListItem>>())
.Concat(_lastScoredGlobalFallbacks ?? Enumerable.Empty<RoScored<IListItem>>());
var tier = ResolveSelectedTier(invoked, packed, _fallbackItems);
if (tier == RankTier.None)
{
return;
}
WeakReferenceMessenger.Default.Send(BuildSearchSelectedMessage(searchText, index, tier));
}
// 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.
private void QueueSearchResultsTelemetry(int queryLength, int resultCount, long latencyMs)
{
lock (_searchTelemetryLock)
{
_pendingSearchTelemetry = (queryLength, resultCount, latencyMs);
}
_searchTelemetryDebounce.Invoke();
}
private void EmitSearchResultsTelemetry()
{
(int QueryLength, int ResultCount, long LatencyMs) snapshot;
lock (_searchTelemetryLock)
{
snapshot = _pendingSearchTelemetry;
}
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)
=> new(query?.Length ?? 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;
}
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
@@ -865,6 +1060,7 @@ public sealed partial class MainListPage : DynamicListPage,
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_fallbackUpdateManager.Dispose();
_searchTelemetryDebounce.Dispose();
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -21,7 +21,9 @@ internal sealed class TelemetryForwarder :
IRecipient<TelemetryBeginInvokeMessage>,
IRecipient<TelemetryInvokeResultMessage>,
IRecipient<TelemetryExtensionInvokedMessage>,
IRecipient<TelemetryDockConfigurationMessage>
IRecipient<TelemetryDockConfigurationMessage>,
IRecipient<TelemetrySearchResultsMessage>,
IRecipient<TelemetrySearchResultSelectedMessage>
{
public TelemetryForwarder()
{
@@ -29,6 +31,8 @@ internal sealed class TelemetryForwarder :
WeakReferenceMessenger.Default.Register<TelemetryInvokeResultMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryExtensionInvokedMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetryDockConfigurationMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetrySearchResultsMessage>(this);
WeakReferenceMessenger.Default.Register<TelemetrySearchResultSelectedMessage>(this);
}
// Message handlers for telemetry events from core layer
@@ -68,6 +72,23 @@ internal sealed class TelemetryForwarder :
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
public static void LogSessionDuration(
ulong durationMs,

View File

@@ -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 = MainListPage.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 = MainListPage.BuildSearchResultsMessage("abc", resultCount: 0, latencyMs: 5);
Assert.IsTrue(noResults.NoResults);
Assert.AreEqual(0, noResults.ResultCount);
var hasResults = MainListPage.BuildSearchResultsMessage("abc", resultCount: 3, latencyMs: 5);
Assert.IsFalse(hasResults.NoResults);
}
[TestMethod]
public void SearchResultsMessage_ClampsNegativeInputs()
{
var message = MainListPage.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 = MainListPage.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, MainListPage.ResolveSelectedTier(exact, packed, fallbackResults: null));
Assert.AreEqual(RankTier.Fuzzy, MainListPage.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, MainListPage.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, MainListPage.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, MainListPage.ResolveVisibleIndex(rendered, a, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(1, MainListPage.ResolveVisibleIndex(rendered, b, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(2, MainListPage.ResolveVisibleIndex(rendered, c, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(-1, MainListPage.ResolveVisibleIndex(rendered, missing, resultsSeparator, fallbacksSeparator));
Assert.AreEqual(-1, MainListPage.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}.");
}
}
}