Files
PowerToys/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/RecentCommandsTests.cs
Jiří Polášek 7477b561a1 CmdPal: Add precomputed fuzzy string matching to Command Palette (#44090)
## Summary of the Pull Request

This PR improves fuzzy matching in Command Palette by:
- Precomputing normalized strings to enable faster comparisons
- Reducing memory allocations during matching, effectively down to zero

It also introduces several behavioral improvements:
- Strips diacritics from the normalized search string to improve
matching across languages
- Suppresses the same-case bonus when the query consists entirely of
lowercase characters -- reflecting typical user input patterns
- Allows skipping word separators -- enabling queries like Power Point
to match PowerPoint

This implementation is currently kept internal and is used only on the
home page. For other scenarios, the `FuzzyStringMatcher` from
`Microsoft.CommandPalette.Extensions.Toolkit` is being improved instead.

`PrecomputedFuzzyMatcher` offers up to a 100× performance improvement
over the current `FuzzyStringMatcher`, and approximately 2–5× better
performance compared to the improved version.

The improvement might seem small, but it adds up and becomes quite
noticeable when filtering the entire home page—whether the user starts a
new search or changes the query non-incrementally (e.g., using
backspace).


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45226
- [x] Closes: #44066
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 13:37:59 -06:00

462 lines
20 KiB
C#

// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.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 RecentCommandsTests : CommandPaletteUnitTestBase
{
private static RecentCommandsManager CreateHistory(IList<string>? commandIds = null)
{
var history = new RecentCommandsManager();
if (commandIds != null)
{
foreach (var item in commandIds)
{
history.AddHistoryItem(item);
}
}
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]
public void ValidateHistoryFunctionality()
{
// Setup
var history = CreateHistory();
// Act
history.AddHistoryItem("com.microsoft.cmdpal.shell");
// Assert
Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0);
}
[TestMethod]
public void ValidateHistoryWeighting()
{
// Setup
var history = CreateBasicHistoryService();
// Act
var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell");
var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker");
var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980");
var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload");
var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command");
// Assert
Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses");
Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency");
Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight");
Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency");
Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight");
}
private sealed partial record ListItemMock(
string Title,
string? Subtitle = "",
string? GivenId = "",
string? ProviderId = "") : IListItem
{
public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId;
public IDetails Details => throw new System.NotImplementedException();
public string Section => throw new System.NotImplementedException();
public ITag[] Tags => throw new System.NotImplementedException();
public string TextToSuggest => throw new System.NotImplementedException();
public ICommand Command => new NoOpCommand() { Id = Id };
public IIconInfo Icon => throw new System.NotImplementedException();
public IContextItem[] MoreCommands => throw new System.NotImplementedException();
#pragma warning disable CS0067
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
#pragma warning restore CS0067
private string GenerateId()
{
// Use WyHash64 to generate stable ID hashes.
// manually seeding with 0, so that the hash is stable across launches
var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0);
return $"{ProviderId}{result}";
}
}
private static RecentCommandsManager CreateHistory(IList<ListItemMock> items)
{
var history = new RecentCommandsManager();
foreach (var item in items)
{
history.AddHistoryItem(item.Id);
}
return history;
}
[TestMethod]
public void ValidateMocksWork()
{
// Setup
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", "idA", "providerA"),
new("Command B", "Subtitle B", GivenId: "idB"),
new("Command C", "Subtitle C", ProviderId: "providerC"),
new("Command A", "Subtitle A", "idA", "providerA"), // Duplicate to test incrementing uses
};
// Act
var history = CreateHistory(items);
// Assert
foreach (var item in items)
{
var weight = history.GetCommandHistoryWeight(item.Id);
Assert.IsTrue(weight > 0, $"Item {item.Title} should have a weight greater than zero.");
}
// Check that the duplicate item has a higher weight due to increased uses
var weightA = history.GetCommandHistoryWeight("idA");
var weightB = history.GetCommandHistoryWeight("idB");
var weightC = history.GetCommandHistoryWeight(items[2].Id); // providerC generated ID
Assert.IsTrue(weightA > weightB, "Item A should have a higher weight than Item B due to more uses.");
Assert.IsTrue(weightA > weightC, "Item A should have a higher weight than Item C due to more uses.");
Assert.AreEqual(weightC, weightB, "Item C and Item B were used in the last 3 commands");
}
[TestMethod]
public void ValidateHistoryBuckets()
{
// Setup
// (these will be checked in reverse order, so that A is the most recent)
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0
new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0
new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0
new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1
new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1
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++)
{
items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}"));
}
// Act
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
// Assert
// First three items should be in the top bucket
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");
Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands");
// Next eight items (3-10 inclusive) should be in the second bucket
var weightD = history.GetCommandHistoryWeight("idD");
var weightE = history.GetCommandHistoryWeight("idE");
var weightF = history.GetCommandHistoryWeight("idF");
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");
Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands");
Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands");
Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands");
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
var weightL = history.GetCommandHistoryWeight("idL");
var weightM = history.GetCommandHistoryWeight("idM");
var weightN = history.GetCommandHistoryWeight("idN");
var weightO = history.GetCommandHistoryWeight("idO");
var weight15 = history.GetCommandHistoryWeight("id15");
Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands");
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
var weight0 = history.GetCommandHistoryWeight(items[0].Id);
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);
Assert.IsTrue(weight3 > weight11);
Assert.IsTrue(weight11 > weight16);
Assert.AreEqual(weight16, weight20);
Assert.AreEqual(weight20, weight30);
Assert.IsTrue(weight30 > weight40);
Assert.AreEqual(weight40, weight49);
// The 50th item has fallen out of the list now
var weight50 = history.GetCommandHistoryWeight("id50");
Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list");
}
[TestMethod]
public void ValidateSimpleScoring()
{
// Setup
var items = new List<ListItemMock>
{
new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0
new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0
new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0
};
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
var scoreA = MainListPage.ScoreTopLevelItem(q, items[0], history, fuzzyMatcher);
var scoreB = MainListPage.ScoreTopLevelItem(q, items[1], history, fuzzyMatcher);
var scoreC = MainListPage.ScoreTopLevelItem(q, items[2], history, fuzzyMatcher);
// Assert
// All of these equally match the query, and they're all in the same bucket,
// so they should all have the same score.
Assert.AreEqual(scoreA, scoreB, "Items A and B should have the same score");
Assert.AreEqual(scoreB, scoreC, "Items B and C should have the same score");
}
private static List<ListItemMock> CreateMockHistoryItems()
{
var items = new List<ListItemMock>
{
new("Visual Studio 2022"), // #0 -> bucket 0
new("Visual Studio Code"), // #1 -> bucket 0
new("Explore Mastodon", GivenId: "social.mastodon.explore"), // #2 -> bucket 0
new("Run commands", Subtitle: "Executes commands (e.g. ping, cmd)", GivenId: "com.microsoft.cmdpal.run"), // #3 -> bucket 1
new("Windows Settings"), // #4 -> bucket 1
new("Command Prompt"), // #5 -> bucket 1
new("Terminal Canary"), // #6 -> bucket 1
};
return items;
}
private static RecentCommandsManager CreateMockHistoryService(List<ListItemMock>? items = null)
{
var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse<ListItemMock>().ToList());
return history;
}
private static IPrecomputedFuzzyMatcher CreateMatcher()
{
return new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
}
private sealed record ScoredItem(ListItemMock Item, int Score)
{
public string Title => Item.Title;
public override string ToString() => $"[{Score}]{Title}";
}
private static IEnumerable<ScoredItem> TieScoresToMatches(List<ListItemMock> items, List<int> scores)
{
if (items.Count != scores.Count)
{
throw new ArgumentException("Items and scores must have the same number of elements");
}
for (var i = 0; i < items.Count; i++)
{
yield return new ScoredItem(items[i], scores[i]);
}
}
private static IEnumerable<ScoredItem> GetMatches(IEnumerable<ScoredItem> scoredItems)
{
var matches = scoredItems
.Where(x => x.Score > 0)
.OrderByDescending(x => x.Score)
.ToList();
return matches;
}
private static IEnumerable<ScoredItem> GetMatches(List<ListItemMock> items, List<int> scores)
{
return GetMatches(TieScoresToMatches(items, scores));
}
[TestMethod]
public void ValidateScoredWeightingSimple()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, emptyHistory, fuzzyMatcher)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items");
for (var i = 0; i < unweightedScores.Count; i++)
{
var unweighted = unweightedScores[i];
var weighted = weightedScores[i];
var item = items[i];
if (item.Title.Contains('C', System.StringComparison.CurrentCultureIgnoreCase))
{
Assert.IsTrue(unweighted >= 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero");
Assert.IsTrue(weighted > unweighted, $"Item {item.Title} should have a higher weighted ({weighted}) score than unweighted ({unweighted})");
}
else
{
Assert.AreEqual(unweighted, 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero");
Assert.AreEqual(unweighted, weighted);
}
}
var unweightedMatches = GetMatches(items, unweightedScores).ToList();
Assert.AreEqual(4, unweightedMatches.Count);
Assert.AreEqual("Command Prompt", unweightedMatches[0].Title, "Command Prompt should be the top match");
Assert.AreEqual("Visual Studio Code", unweightedMatches[1].Title, "Visual Studio Code should be the second match");
Assert.AreEqual("Terminal Canary", unweightedMatches[2].Title);
Assert.AreEqual("Run commands", unweightedMatches[3].Title);
// Even after weighting for 1 use, Command Prompt should still be the top match.
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);
Assert.AreEqual("Command Prompt", weightedMatches[0].Title);
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title);
Assert.AreEqual("Terminal Canary", weightedMatches[2].Title);
Assert.AreEqual("Run commands", weightedMatches[3].Title);
}
[TestMethod]
public void ValidateTitlesAreMoreImportantThanHistory()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
// Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches
// the title better
Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match");
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal");
Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle");
}
[TestMethod]
public void ValidateTitlesAreMoreImportantThanUsage()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
// Add extra uses of VS Code to try and push it above Terminal
for (var i = 0; i < 10; i++)
{
history.AddHistoryItem(items[1].Id);
}
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
// Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches
// the title better
Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match");
Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal");
Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle");
}
[TestMethod]
public void ValidateUsageEventuallyHelps()
{
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
// We're gonna run this test and keep adding more uses of VS Code till
// it breaks past Command Prompt
var vsCodeId = items[1].Id;
for (var i = 0; i < 10; i++)
{
history.AddHistoryItem(vsCodeId);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);
var expectedCmdIndex = i < 5 ? 0 : 1;
var expectedCodeIndex = i < 5 ? 1 : 0;
Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title);
Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title);
}
}
}