From 96b9e35b07ef8ba00a96ccedbd06d8a3f0c28ce7 Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Wed, 8 Jul 2026 02:41:36 -0500 Subject: [PATCH] [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> --- .../Commands/MainListPage.cs | 198 +++++++++++++++++- .../TelemetrySearchResultSelectedMessage.cs | 15 ++ .../Messages/TelemetrySearchResultsMessage.cs | 12 ++ .../Events/CmdPalSearchResultSelected.cs | 50 +++++ .../Events/CmdPalSearchResults.cs | 55 +++++ .../Helpers/TelemetryForwarder.cs | 23 +- .../SearchTelemetryTests.cs | 198 ++++++++++++++++++ 7 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SearchTelemetryTests.cs diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 22d964bb20..11c14a9e7f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -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? _lastSearchViewItems; + private IReadOnlyList>? _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>()) + .Concat(_filteredApps ?? Enumerable.Empty>()) + .Concat(_lastScoredGlobalFallbacks ?? Enumerable.Empty>()); + + 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? 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>? packedResults, + IEnumerable>? 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; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs new file mode 100644 index 0000000000..cb79b3f562 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultSelectedMessage.cs @@ -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; + +/// +/// 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. is the query's character count, +/// is the zero-based rank of the selected result, and is its ranker tier. +/// +public record TelemetrySearchResultSelectedMessage(int QueryLength, int SelectedIndex, RankTier SelectedTier); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs new file mode 100644 index 0000000000..1d970c33de --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TelemetrySearchResultsMessage.cs @@ -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; + +/// +/// Telemetry message sent when a main-page search query settles. +/// Carries only non-identifying aggregates - never the raw query text. +/// is the query's character count. +/// +public record TelemetrySearchResultsMessage(int QueryLength, int ResultCount, bool NoResults, ulong LatencyMs); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs new file mode 100644 index 0000000000..62bc909dce --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResultSelected.cs @@ -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; + +/// +/// 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 , which respects the existing +/// PowerToys data-diagnostics consent gate. +/// +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalSearchResultSelected : EventBase, IEvent +{ + /// + /// Gets or sets the character length of the query at selection (never the query text). + /// + public int QueryLength { get; set; } + + /// + /// Gets or sets the zero-based rank of the selected result within the visible results. + /// + public int SelectedIndex { get; set; } + + /// + /// 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. + /// + 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; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs new file mode 100644 index 0000000000..5b0e1cbf26 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Events/CmdPalSearchResults.cs @@ -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; + +/// +/// 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 , which respects the +/// existing PowerToys data-diagnostics consent gate. +/// +[EventData] +[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] +public class CmdPalSearchResults : EventBase, IEvent +{ + /// + /// Gets or sets the character length of the query (never the query text itself). + /// + public int QueryLength { get; set; } + + /// + /// Gets or sets the number of deterministic first-paint results (commands and apps) + /// produced for the query. + /// + public int ResultCount { get; set; } + + /// + /// Gets or sets a value indicating whether the query produced no deterministic results. + /// + public bool NoResults { get; set; } + + /// + /// Gets or sets the time in milliseconds to produce the deterministic first-paint results. + /// + 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; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs index 84fab2734b..267b8d2a33 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TelemetryForwarder.cs @@ -21,7 +21,9 @@ internal sealed class TelemetryForwarder : IRecipient, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient, + IRecipient { public TelemetryForwarder() { @@ -29,6 +31,8 @@ internal sealed class TelemetryForwarder : WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(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, diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SearchTelemetryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SearchTelemetryTests.cs new file mode 100644 index 0000000000..a9142f77cd --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/SearchTelemetryTests.cs @@ -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; + +/// +/// 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. +/// +[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? PropChanged; +#pragma warning restore CS0067 // The event is never used + + public override string ToString() => Title; + } + + private static RoScored 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> + { + 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> { 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> { 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()) + { + if (tier == RankTier.None) + { + continue; + } + + var packed = MainListRanker.Pack(tier, withinTierScore: 42); + Assert.AreEqual(tier, MainListRanker.TierOf(packed), $"TierOf should round-trip {tier}."); + } + } +}