mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-05 18:57:19 +02:00
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
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// 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.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PrecomputedFuzzyMatcherEmojiTests
|
||||
{
|
||||
private readonly PrecomputedFuzzyMatcher _matcher = new();
|
||||
|
||||
[TestMethod]
|
||||
public void ExactMatch_SimpleEmoji_ReturnsScore()
|
||||
{
|
||||
const string needle = "🚀";
|
||||
const string haystack = "Launch 🚀 sequence";
|
||||
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match for simple emoji");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExactMatch_SkinTone_ReturnsScore()
|
||||
{
|
||||
const string needle = "👍🏽"; // Medium skin tone
|
||||
const string haystack = "Thumbs up 👍🏽 here";
|
||||
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match for emoji with skin tone");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ZWJSequence_Family_Match()
|
||||
{
|
||||
const string needle = "👨👩👧👦"; // Family: Man, Woman, Girl, Boy
|
||||
const string haystack = "Emoji 👨👩👧👦 Test";
|
||||
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match for ZWJ sequence");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Flags_Match()
|
||||
{
|
||||
const string needle = "🇺🇸"; // US Flag (Regional Indicator U + Regional Indicator S)
|
||||
const string haystack = "USA 🇺🇸";
|
||||
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match for flag emoji");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Emoji_MixedWithText_Search()
|
||||
{
|
||||
const string needle = "t🌮o"; // "t" + taco + "o"
|
||||
const string haystack = "taco 🌮 on tuesday";
|
||||
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match for emoji mixed with text");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PrecomputedFuzzyMatcherOptionsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Score_RemoveDiacriticsOption_AffectsMatching()
|
||||
{
|
||||
var withDiacriticsRemoved = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true });
|
||||
var withoutDiacriticsRemoved = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false });
|
||||
|
||||
const string needle = "cafe";
|
||||
const string haystack = "CAFÉ";
|
||||
|
||||
var scoreWithRemoval = withDiacriticsRemoved.Score(
|
||||
withDiacriticsRemoved.PrecomputeQuery(needle),
|
||||
withDiacriticsRemoved.PrecomputeTarget(haystack));
|
||||
var scoreWithoutRemoval = withoutDiacriticsRemoved.Score(
|
||||
withoutDiacriticsRemoved.PrecomputeQuery(needle),
|
||||
withoutDiacriticsRemoved.PrecomputeTarget(haystack));
|
||||
|
||||
Assert.IsTrue(scoreWithRemoval > 0, "Expected match when diacritics are removed.");
|
||||
Assert.AreEqual(0, scoreWithoutRemoval, "Expected no match when diacritics are preserved.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_SkipWordSeparatorsOption_AffectsMatching()
|
||||
{
|
||||
var skipSeparators = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = true });
|
||||
var keepSeparators = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = false });
|
||||
|
||||
const string needle = "a b";
|
||||
const string haystack = "ab";
|
||||
|
||||
var scoreSkip = skipSeparators.Score(
|
||||
skipSeparators.PrecomputeQuery(needle),
|
||||
skipSeparators.PrecomputeTarget(haystack));
|
||||
var scoreKeep = keepSeparators.Score(
|
||||
keepSeparators.PrecomputeQuery(needle),
|
||||
keepSeparators.PrecomputeTarget(haystack));
|
||||
|
||||
Assert.IsTrue(scoreSkip > 0, "Expected match when word separators are skipped.");
|
||||
Assert.AreEqual(0, scoreKeep, "Expected no match when word separators are preserved.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_IgnoreSameCaseBonusOption_AffectsLowercaseQuery()
|
||||
{
|
||||
var ignoreSameCase = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions
|
||||
{
|
||||
IgnoreSameCaseBonusIfQueryIsAllLowercase = true,
|
||||
SameCaseBonus = 10,
|
||||
});
|
||||
var applySameCase = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions
|
||||
{
|
||||
IgnoreSameCaseBonusIfQueryIsAllLowercase = false,
|
||||
SameCaseBonus = 10,
|
||||
});
|
||||
|
||||
const string needle = "test";
|
||||
const string haystack = "test";
|
||||
|
||||
var scoreIgnore = ignoreSameCase.Score(
|
||||
ignoreSameCase.PrecomputeQuery(needle),
|
||||
ignoreSameCase.PrecomputeTarget(haystack));
|
||||
var scoreApply = applySameCase.Score(
|
||||
applySameCase.PrecomputeQuery(needle),
|
||||
applySameCase.PrecomputeTarget(haystack));
|
||||
|
||||
Assert.IsTrue(scoreApply > scoreIgnore, "Expected same-case bonus to apply when not ignored.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PrecomputedFuzzyMatcherSecondaryInputTests
|
||||
{
|
||||
private readonly PrecomputedFuzzyMatcher _matcher = new();
|
||||
private readonly StringFolder _folder = new();
|
||||
private readonly BloomFilter _bloom = new();
|
||||
|
||||
[TestMethod]
|
||||
public void Score_PrimaryQueryMatchesSecondaryTarget_ShouldMatch()
|
||||
{
|
||||
// Scenario: Searching for "calc" should match a file "calculator.exe" where primary is filename, secondary is path
|
||||
var query = CreateQuery("calc");
|
||||
var target = CreateTarget(primary: "important.txt", secondary: "C:\\Programs\\Calculator\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected primary query to match secondary target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_SecondaryQueryMatchesPrimaryTarget_ShouldMatch()
|
||||
{
|
||||
// Scenario: User types "documents\\report" and we want to match against filename
|
||||
var query = CreateQuery(primary: "documents", secondary: "report");
|
||||
var target = CreateTarget(primary: "report.docx");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected secondary query to match primary target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_SecondaryQueryMatchesSecondaryTarget_ShouldMatch()
|
||||
{
|
||||
// Scenario: Both query and target have secondary info that matches
|
||||
var query = CreateQuery(primary: "test", secondary: "documents");
|
||||
var target = CreateTarget(primary: "something.txt", secondary: "C:\\Users\\Documents\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected secondary query to match secondary target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_PrimaryQueryMatchesBothTargets_ShouldReturnBestScore()
|
||||
{
|
||||
// The same query matches both primary and secondary of target
|
||||
var query = CreateQuery("test");
|
||||
var target = CreateTarget(primary: "test.txt", secondary: "test_folder\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected query to match when it appears in both primary and secondary");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_NoSecondaryInQuery_MatchesSecondaryTarget()
|
||||
{
|
||||
// Query without secondary can still match target's secondary
|
||||
var query = CreateQuery("downloads");
|
||||
var target = CreateTarget(primary: "file.txt", secondary: "C:\\Downloads\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected primary query to match secondary target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_NoSecondaryInTarget_SecondaryQueryShouldNotMatch()
|
||||
{
|
||||
// Query with secondary but target without secondary - secondary query shouldn't interfere
|
||||
var query = CreateQuery(primary: "test", secondary: "extra");
|
||||
var target = CreateTarget(primary: "test.txt");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
// Primary should still match, secondary query just doesn't contribute
|
||||
Assert.IsTrue(score > 0, "Expected primary query to match primary target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_SecondaryQueryNoMatch_PrimaryCanStillMatch()
|
||||
{
|
||||
// Secondary doesn't match anything, but primary does
|
||||
var query = CreateQuery(primary: "file", secondary: "nomatch");
|
||||
var target = CreateTarget(primary: "myfile.txt", secondary: "C:\\Documents\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected primary query to match even when secondary doesn't");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_OnlySecondaryMatches_ShouldReturnScore()
|
||||
{
|
||||
// Only the secondary parts match, primary doesn't
|
||||
var query = CreateQuery(primary: "xyz", secondary: "documents");
|
||||
var target = CreateTarget(primary: "abc.txt", secondary: "C:\\Users\\Documents\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match when only secondary parts match");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_BothQueriesMatchDifferentTargets_ShouldReturnBestScore()
|
||||
{
|
||||
// Primary query matches secondary target, secondary query matches primary target
|
||||
var query = CreateQuery(primary: "docs", secondary: "report");
|
||||
var target = CreateTarget(primary: "report.pdf", secondary: "C:\\Documents\\");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match when queries cross-match with targets");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_CompletelyDifferent_ShouldNotMatch()
|
||||
{
|
||||
var query = CreateQuery(primary: "xyz", secondary: "abc");
|
||||
var target = CreateTarget(primary: "hello", secondary: "world");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.AreEqual(0, score, "Expected no match when nothing matches");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_EmptySecondaryInputs_ShouldMatchOnPrimary()
|
||||
{
|
||||
var query = CreateQuery(primary: "test", secondary: string.Empty);
|
||||
var target = CreateTarget(primary: "test.txt", secondary: string.Empty);
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match on primary when secondaries are empty");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_WordSeparatorMatching_AcrossSecondary()
|
||||
{
|
||||
// Test that "Power Point" matches "PowerPoint" using secondary
|
||||
var query = CreateQuery(primary: "power", secondary: "point");
|
||||
var target = CreateTarget(primary: "PowerPoint.exe");
|
||||
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected 'power' + 'point' to match 'PowerPoint'");
|
||||
}
|
||||
|
||||
private FuzzyQuery CreateQuery(string primary, string? secondary = null)
|
||||
{
|
||||
var primaryFolded = _folder.Fold(primary, removeDiacritics: true);
|
||||
var primaryBloom = _bloom.Compute(primaryFolded);
|
||||
var primaryEffectiveLength = primaryFolded.Length;
|
||||
var primaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(primary);
|
||||
|
||||
string? secondaryFolded = null;
|
||||
ulong secondaryBloom = 0;
|
||||
var secondaryEffectiveLength = 0;
|
||||
var secondaryIsAllLowercase = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(secondary))
|
||||
{
|
||||
secondaryFolded = _folder.Fold(secondary, removeDiacritics: true);
|
||||
secondaryBloom = _bloom.Compute(secondaryFolded);
|
||||
secondaryEffectiveLength = secondaryFolded.Length;
|
||||
secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondary);
|
||||
}
|
||||
|
||||
return new FuzzyQuery(
|
||||
original: primary,
|
||||
folded: primaryFolded,
|
||||
bloom: primaryBloom,
|
||||
effectiveLength: primaryEffectiveLength,
|
||||
isAllLowercaseAsciiOrNonLetter: primaryIsAllLowercase,
|
||||
secondaryOriginal: secondary,
|
||||
secondaryFolded: secondaryFolded,
|
||||
secondaryBloom: secondaryBloom,
|
||||
secondaryEffectiveLength: secondaryEffectiveLength,
|
||||
secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase);
|
||||
}
|
||||
|
||||
private FuzzyTarget CreateTarget(string primary, string? secondary = null)
|
||||
{
|
||||
var primaryFolded = _folder.Fold(primary, removeDiacritics: true);
|
||||
var primaryBloom = _bloom.Compute(primaryFolded);
|
||||
|
||||
string? secondaryFolded = null;
|
||||
ulong secondaryBloom = 0;
|
||||
|
||||
if (!string.IsNullOrEmpty(secondary))
|
||||
{
|
||||
secondaryFolded = _folder.Fold(secondary, removeDiacritics: true);
|
||||
secondaryBloom = _bloom.Compute(secondaryFolded);
|
||||
}
|
||||
|
||||
return new FuzzyTarget(
|
||||
original: primary,
|
||||
folded: primaryFolded,
|
||||
bloom: primaryBloom,
|
||||
secondaryOriginal: secondary,
|
||||
secondaryFolded: secondaryFolded,
|
||||
secondaryBloom: secondaryBloom);
|
||||
}
|
||||
|
||||
private static bool IsAllLowercaseAsciiOrNonLetter(string s)
|
||||
{
|
||||
foreach (var c in s)
|
||||
{
|
||||
if ((uint)(c - 'A') <= ('Z' - 'A'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public class PrecomputedFuzzyMatcherTests
|
||||
{
|
||||
private readonly PrecomputedFuzzyMatcher _matcher = new();
|
||||
|
||||
public static IEnumerable<object[]> MatchData =>
|
||||
[
|
||||
["a", "a"],
|
||||
["abc", "abc"],
|
||||
["a", "ab"],
|
||||
["b", "ab"],
|
||||
["abc", "axbycz"],
|
||||
["pt", "PowerToys"],
|
||||
["calc", "Calculator"],
|
||||
["vs", "Visual Studio"],
|
||||
["code", "Visual Studio Code"],
|
||||
|
||||
// Diacritics
|
||||
["abc", "ÁBC"],
|
||||
|
||||
// Separators
|
||||
["p/t", "power\\toys"],
|
||||
];
|
||||
|
||||
public static IEnumerable<object[]> NonMatchData =>
|
||||
[
|
||||
["z", "abc"],
|
||||
["verylongstring", "short"],
|
||||
];
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(MatchData))]
|
||||
public void Score_Matches_ShouldHavePositiveScore(string needle, string haystack)
|
||||
{
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(NonMatchData))]
|
||||
public void Score_NonMatches_ShouldHaveZeroScore(string needle, string haystack)
|
||||
{
|
||||
var query = _matcher.PrecomputeQuery(needle);
|
||||
var target = _matcher.PrecomputeTarget(haystack);
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.AreEqual(0, score, $"Expected 0 score for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_EmptyQuery_ReturnsZero()
|
||||
{
|
||||
var query = _matcher.PrecomputeQuery(string.Empty);
|
||||
var target = _matcher.PrecomputeTarget("something");
|
||||
Assert.AreEqual(0, _matcher.Score(query, target));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_EmptyTarget_ReturnsZero()
|
||||
{
|
||||
var query = _matcher.PrecomputeQuery("something");
|
||||
var target = _matcher.PrecomputeTarget(string.Empty);
|
||||
Assert.AreEqual(0, _matcher.Score(query, target));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SchemaId_DefaultMatcher_IsConsistent()
|
||||
{
|
||||
var matcher1 = new PrecomputedFuzzyMatcher();
|
||||
var matcher2 = new PrecomputedFuzzyMatcher();
|
||||
|
||||
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Default matchers should have the same SchemaId");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SchemaId_SameOptions_ProducesSameId()
|
||||
{
|
||||
var options = new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true };
|
||||
var matcher1 = new PrecomputedFuzzyMatcher(options);
|
||||
var matcher2 = new PrecomputedFuzzyMatcher(options);
|
||||
|
||||
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Matchers with same options should have the same SchemaId");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SchemaId_DifferentRemoveDiacriticsOption_ProducesDifferentId()
|
||||
{
|
||||
var matcherWithDiacriticsRemoval = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true });
|
||||
var matcherWithoutDiacriticsRemoval = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false });
|
||||
|
||||
Assert.AreNotEqual(
|
||||
matcherWithDiacriticsRemoval.SchemaId,
|
||||
matcherWithoutDiacriticsRemoval.SchemaId,
|
||||
"Different RemoveDiacritics option should produce different SchemaId");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SchemaId_ScoringOptionsDoNotAffectId()
|
||||
{
|
||||
// SchemaId should only be affected by options that affect folding/bloom, not scoring
|
||||
var matcher1 = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 1, CamelCaseBonus = 2 });
|
||||
var matcher2 = new PrecomputedFuzzyMatcher(
|
||||
new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 100, CamelCaseBonus = 200 });
|
||||
|
||||
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Scoring options should not affect SchemaId");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_WordSeparatorMatching_PowerPoint()
|
||||
{
|
||||
// Test that "Power Point" can match "PowerPoint" when word separators are skipped
|
||||
var query = _matcher.PrecomputeQuery("Power Point");
|
||||
var target = _matcher.PrecomputeTarget("PowerPoint");
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected 'Power Point' to match 'PowerPoint'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_WordSeparatorMatching_UnderscoreDash()
|
||||
{
|
||||
// Test that different word separators match each other
|
||||
var query = _matcher.PrecomputeQuery("hello_world");
|
||||
var target = _matcher.PrecomputeTarget("hello-world");
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected 'hello_world' to match 'hello-world'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_WordSeparatorMatching_MixedSeparators()
|
||||
{
|
||||
// Test multiple different separators
|
||||
var query = _matcher.PrecomputeQuery("my.file_name");
|
||||
var target = _matcher.PrecomputeTarget("my-file.name");
|
||||
var score = _matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected mixed separators to match");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_PrecomputedQueryReuse_ShouldWorkConsistently()
|
||||
{
|
||||
// Test that precomputed query can be reused across multiple targets
|
||||
var query = _matcher.PrecomputeQuery("test");
|
||||
var target1 = _matcher.PrecomputeTarget("test123");
|
||||
var target2 = _matcher.PrecomputeTarget("mytest");
|
||||
var target3 = _matcher.PrecomputeTarget("unrelated");
|
||||
|
||||
var score1 = _matcher.Score(query, target1);
|
||||
var score2 = _matcher.Score(query, target2);
|
||||
var score3 = _matcher.Score(query, target3);
|
||||
|
||||
Assert.IsTrue(score1 > 0, "Expected query to match first target");
|
||||
Assert.IsTrue(score2 > 0, "Expected query to match second target");
|
||||
Assert.AreEqual(0, score3, "Expected query not to match third target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_PrecomputedTargetReuse_ShouldWorkConsistently()
|
||||
{
|
||||
// Test that precomputed target can be reused across multiple queries
|
||||
var target = _matcher.PrecomputeTarget("calculator");
|
||||
var query1 = _matcher.PrecomputeQuery("calc");
|
||||
var query2 = _matcher.PrecomputeQuery("lator");
|
||||
var query3 = _matcher.PrecomputeQuery("xyz");
|
||||
|
||||
var score1 = _matcher.Score(query1, target);
|
||||
var score2 = _matcher.Score(query2, target);
|
||||
var score3 = _matcher.Score(query3, target);
|
||||
|
||||
Assert.IsTrue(score1 > 0, "Expected first query to match target");
|
||||
Assert.IsTrue(score2 > 0, "Expected second query to match target");
|
||||
Assert.AreEqual(0, score3, "Expected third query not to match target");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_CaseInsensitiveMatching_Works()
|
||||
{
|
||||
// Test various case combinations
|
||||
var query1 = _matcher.PrecomputeQuery("test");
|
||||
var query2 = _matcher.PrecomputeQuery("TEST");
|
||||
var query3 = _matcher.PrecomputeQuery("TeSt");
|
||||
|
||||
var target = _matcher.PrecomputeTarget("TestFile");
|
||||
|
||||
var score1 = _matcher.Score(query1, target);
|
||||
var score2 = _matcher.Score(query2, target);
|
||||
var score3 = _matcher.Score(query3, target);
|
||||
|
||||
Assert.IsTrue(score1 > 0, "Expected lowercase query to match");
|
||||
Assert.IsTrue(score2 > 0, "Expected uppercase query to match");
|
||||
Assert.IsTrue(score3 > 0, "Expected mixed case query to match");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public sealed class PrecomputedFuzzyMatcherUnicodeTests
|
||||
{
|
||||
private readonly PrecomputedFuzzyMatcher _defaultMatcher = new();
|
||||
|
||||
[TestMethod]
|
||||
public void UnpairedHighSurrogateInNeedle_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "\uD83D"; // high surrogate (unpaired)
|
||||
const string haystack = "abc";
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnpairedLowSurrogateInNeedle_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "\uDC00"; // low surrogate (unpaired)
|
||||
const string haystack = "abc";
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UnpairedHighSurrogateInHaystack_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "a";
|
||||
const string haystack = "a\uD83D" + "bc"; // inject unpaired high surrogate
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MixedSurrogatesAndMarks_ShouldNotThrow()
|
||||
{
|
||||
// "Garbage smoothie": unpaired surrogate + combining mark + emoji surrogate pair
|
||||
const string needle = "a\uD83D\u0301"; // 'a' + unpaired high surrogate + combining acute
|
||||
const string haystack = "a\u0301 \U0001F600"; // 'a' + combining acute + space + 😀 (valid pair)
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidEmojiSurrogatePair_ShouldNotThrow_AndCanMatch()
|
||||
{
|
||||
// 😀 U+1F600 encoded as surrogate pair in UTF-16
|
||||
const string needle = "\U0001F600";
|
||||
const string haystack = "x \U0001F600 y";
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
var score = _defaultMatcher.Score(q, t);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected emoji to produce a match score > 0.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RandomUtf16Garbage_ShouldNotThrow()
|
||||
{
|
||||
// Deterministic pseudo-random "UTF-16 garbage", including surrogates.
|
||||
var s1 = MakeDeterministicGarbage(seed: 1234, length: 512);
|
||||
var s2 = MakeDeterministicGarbage(seed: 5678, length: 1024);
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(s1);
|
||||
var t = _defaultMatcher.PrecomputeTarget(s2);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HighSurrogateAtEndOfHaystack_ShouldNotThrow()
|
||||
{
|
||||
const string needle = "a";
|
||||
const string haystack = "abc\uD83D"; // Ends with high surrogate
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VeryLongStrings_ShouldNotThrow()
|
||||
{
|
||||
var needle = new string('a', 100);
|
||||
var haystack = new string('b', 10000) + needle + new string('c', 10000);
|
||||
|
||||
var q = _defaultMatcher.PrecomputeQuery(needle);
|
||||
var t = _defaultMatcher.PrecomputeTarget(haystack);
|
||||
_ = _defaultMatcher.Score(q, t);
|
||||
}
|
||||
|
||||
private static string MakeDeterministicGarbage(int seed, int length)
|
||||
{
|
||||
// LCG for deterministic generation without Random’s platform/version surprises.
|
||||
var x = (uint)seed;
|
||||
var chars = length <= 2048 ? stackalloc char[length] : new char[length];
|
||||
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
{
|
||||
// LCG: x = (a*x + c) mod 2^32
|
||||
x = unchecked((1664525u * x) + 1013904223u);
|
||||
|
||||
// Take top 16 bits as UTF-16 code unit (includes surrogates).
|
||||
chars[i] = (char)(x >> 16);
|
||||
}
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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.Globalization;
|
||||
using Microsoft.CmdPal.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public class PrecomputedFuzzyMatcherWithPinyinTests
|
||||
{
|
||||
private PrecomputedFuzzyMatcherWithPinyin CreateMatcher(PinyinMode mode = PinyinMode.On, bool removeApostrophes = true)
|
||||
{
|
||||
return new PrecomputedFuzzyMatcherWithPinyin(
|
||||
new PrecomputedFuzzyMatcherOptions(),
|
||||
new PinyinFuzzyMatcherOptions { Mode = mode, RemoveApostrophesForQuery = removeApostrophes },
|
||||
new StringFolder(),
|
||||
new BloomFilter());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("bj", "北京")]
|
||||
[DataRow("sh", "上海")]
|
||||
[DataRow("nihao", "你好")]
|
||||
[DataRow("beijing", "北京")]
|
||||
[DataRow("ce", "测试")]
|
||||
public void Score_PinyinMatches_ShouldHavePositiveScore(string needle, string haystack)
|
||||
{
|
||||
var matcher = CreateMatcher(PinyinMode.On);
|
||||
var query = matcher.PrecomputeQuery(needle);
|
||||
var target = matcher.PrecomputeTarget(haystack);
|
||||
var score = matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_PinyinOff_ShouldNotMatchPinyin()
|
||||
{
|
||||
var matcher = CreateMatcher(PinyinMode.Off);
|
||||
var needle = "bj";
|
||||
var haystack = "北京";
|
||||
|
||||
var query = matcher.PrecomputeQuery(needle);
|
||||
var target = matcher.PrecomputeTarget(haystack);
|
||||
var score = matcher.Score(query, target);
|
||||
|
||||
Assert.AreEqual(0, score, "Pinyin match should be disabled.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_StandardMatch_WorksWithPinyinMatcher()
|
||||
{
|
||||
var matcher = CreateMatcher(PinyinMode.On);
|
||||
var needle = "abc";
|
||||
var haystack = "abc";
|
||||
|
||||
var query = matcher.PrecomputeQuery(needle);
|
||||
var target = matcher.PrecomputeTarget(haystack);
|
||||
var score = matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Standard match should still work.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Score_ApostropheRemoval_Works()
|
||||
{
|
||||
var matcher = CreateMatcher(PinyinMode.On, removeApostrophes: true);
|
||||
var needle = "xi'an";
|
||||
|
||||
// "xi'an" -> "xian" -> matches "西安" (Xi An)
|
||||
var haystack = "西安";
|
||||
|
||||
var query = matcher.PrecomputeQuery(needle);
|
||||
var target = matcher.PrecomputeTarget(haystack);
|
||||
var score = matcher.Score(query, target);
|
||||
|
||||
Assert.IsTrue(score > 0, "Expected match for 'xi'an' -> '西安' with apostrophe removal.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AutoMode_EnablesForChineseCulture()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("zh-CN");
|
||||
var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi);
|
||||
|
||||
var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京"));
|
||||
Assert.IsTrue(score > 0, "Should match when UI culture is zh-CN");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = originalCulture;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void AutoMode_DisablesForNonChineseCulture()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("en-US");
|
||||
var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi);
|
||||
|
||||
var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京"));
|
||||
Assert.AreEqual(0, score, "Should NOT match when UI culture is en-US");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentUICulture = originalCulture;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 Microsoft.CmdPal.Core.Common.Text;
|
||||
|
||||
namespace Microsoft.CmdPal.Common.UnitTests.Text;
|
||||
|
||||
[TestClass]
|
||||
public class StringFolderTests
|
||||
{
|
||||
private readonly StringFolder _folder = new();
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(null, "")]
|
||||
[DataRow("", "")]
|
||||
[DataRow("abc", "ABC")]
|
||||
[DataRow("ABC", "ABC")]
|
||||
[DataRow("a\\b", "A/B")]
|
||||
[DataRow("a/b", "A/B")]
|
||||
[DataRow("ÁBC", "ABC")] // Diacritic removal
|
||||
[DataRow("ñ", "N")]
|
||||
[DataRow("hello world", "HELLO WORLD")]
|
||||
public void Fold_RemoveDiacritics_Works(string input, string expected)
|
||||
{
|
||||
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("abc", "ABC")]
|
||||
[DataRow("ÁBC", "ÁBC")] // No diacritic removal
|
||||
[DataRow("a\\b", "A/B")]
|
||||
public void Fold_KeepDiacritics_Works(string input, string expected)
|
||||
{
|
||||
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: false));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Fold_IsAlreadyFolded_ReturnsSameInstance()
|
||||
{
|
||||
var input = "ALREADY/FOLDED";
|
||||
var result = _folder.Fold(input, removeDiacritics: true);
|
||||
Assert.AreSame(input, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Fold_WithNonAsciiButNoDiacritics_ReturnsFolded()
|
||||
{
|
||||
// E.g. Cyrillic or other scripts that might not decompose in a simple way or just upper case
|
||||
// "привет" -> "ПРИВЕТ"
|
||||
var input = "привет";
|
||||
var expected = "ПРИВЕТ";
|
||||
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Commands;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
@@ -43,38 +44,34 @@ public partial class MainListPageResultFactoryTests
|
||||
public override string ToString() => Title;
|
||||
}
|
||||
|
||||
private static Scored<IListItem> S(string title, int score)
|
||||
private static RoScored<IListItem> S(string title, int score)
|
||||
{
|
||||
return new Scored<IListItem>
|
||||
{
|
||||
Score = score,
|
||||
Item = new MockListItem { Title = title },
|
||||
};
|
||||
return new RoScored<IListItem>(score: score, item: new MockListItem { Title = title });
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Merge_PrioritizesListsCorrectly()
|
||||
{
|
||||
var filtered = new List<Scored<IListItem>>
|
||||
var filtered = new List<RoScored<IListItem>>
|
||||
{
|
||||
S("F1", 100),
|
||||
S("F2", 50),
|
||||
};
|
||||
|
||||
var scoredFallback = new List<Scored<IListItem>>
|
||||
var scoredFallback = new List<RoScored<IListItem>>
|
||||
{
|
||||
S("SF1", 100),
|
||||
S("SF2", 60),
|
||||
};
|
||||
|
||||
var apps = new List<Scored<IListItem>>
|
||||
var apps = new List<RoScored<IListItem>>
|
||||
{
|
||||
S("A1", 100),
|
||||
S("A2", 55),
|
||||
};
|
||||
|
||||
// Fallbacks are not scored.
|
||||
var fallbacks = new List<Scored<IListItem>>
|
||||
var fallbacks = new List<RoScored<IListItem>>
|
||||
{
|
||||
S("FB1", 0),
|
||||
S("FB2", 0),
|
||||
@@ -104,7 +101,7 @@ public partial class MainListPageResultFactoryTests
|
||||
[TestMethod]
|
||||
public void Merge_AppliesAppLimit()
|
||||
{
|
||||
var apps = new List<Scored<IListItem>>
|
||||
var apps = new List<RoScored<IListItem>>
|
||||
{
|
||||
S("A1", 100),
|
||||
S("A2", 90),
|
||||
@@ -126,7 +123,7 @@ public partial class MainListPageResultFactoryTests
|
||||
[TestMethod]
|
||||
public void Merge_FiltersEmptyFallbacks()
|
||||
{
|
||||
var fallbacks = new List<Scored<IListItem>>
|
||||
var fallbacks = new List<RoScored<IListItem>>
|
||||
{
|
||||
S("FB1", 0),
|
||||
S("FB3", 0),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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;
|
||||
@@ -263,10 +264,12 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
};
|
||||
|
||||
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
|
||||
var fuzzyMatcher = CreateMatcher();
|
||||
var q = fuzzyMatcher.PrecomputeQuery("C");
|
||||
|
||||
var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history);
|
||||
var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history);
|
||||
var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history);
|
||||
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,
|
||||
@@ -296,6 +299,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
return history;
|
||||
}
|
||||
|
||||
private static IPrecomputedFuzzyMatcher CreateMatcher()
|
||||
{
|
||||
return new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
|
||||
}
|
||||
|
||||
private sealed record ScoredItem(ListItemMock Item, int Score)
|
||||
{
|
||||
public string Title => Item.Title;
|
||||
@@ -337,9 +345,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
var items = CreateMockHistoryItems();
|
||||
var emptyHistory = CreateMockHistoryService(new());
|
||||
var history = CreateMockHistoryService(items);
|
||||
var fuzzyMatcher = CreateMatcher();
|
||||
|
||||
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList();
|
||||
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
|
||||
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++)
|
||||
{
|
||||
@@ -380,7 +390,10 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
var items = CreateMockHistoryItems();
|
||||
var emptyHistory = CreateMockHistoryService(new());
|
||||
var history = CreateMockHistoryService(items);
|
||||
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
|
||||
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");
|
||||
@@ -398,6 +411,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
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++)
|
||||
@@ -405,7 +420,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
history.AddHistoryItem(items[1].Id);
|
||||
}
|
||||
|
||||
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
|
||||
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");
|
||||
@@ -423,6 +438,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
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
|
||||
@@ -431,7 +448,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
|
||||
{
|
||||
history.AddHistoryItem(vsCodeId);
|
||||
|
||||
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
|
||||
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
|
||||
var weightedMatches = GetMatches(items, weightedScores).ToList();
|
||||
Assert.AreEqual(4, weightedMatches.Count);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user