mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-08 11:29:58 +02:00
[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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user