/ src / modules / cmdpal / Tests / Microsoft.CmdPal.UI.ViewModels.UnitTests / RecentCommandsTests.cs
RecentCommandsTests.cs
1 // Copyright (c) Microsoft Corporation 2 // The Microsoft Corporation licenses this file to you under the MIT license. 3 // See the LICENSE file in the project root for more information. 4 5 using System; 6 using System.Collections.Generic; 7 using System.Linq; 8 using Microsoft.CmdPal.Ext.UnitTestBase; 9 using Microsoft.CmdPal.UI.ViewModels.MainPage; 10 using Microsoft.CommandPalette.Extensions; 11 using Microsoft.CommandPalette.Extensions.Toolkit; 12 using Microsoft.VisualStudio.TestTools.UnitTesting; 13 using Windows.Foundation; 14 using WyHash; 15 16 namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; 17 18 [TestClass] 19 public partial class RecentCommandsTests : CommandPaletteUnitTestBase 20 { 21 private static RecentCommandsManager CreateHistory(IList<string>? commandIds = null) 22 { 23 var history = new RecentCommandsManager(); 24 if (commandIds != null) 25 { 26 foreach (var item in commandIds) 27 { 28 history.AddHistoryItem(item); 29 } 30 } 31 32 return history; 33 } 34 35 private static RecentCommandsManager CreateBasicHistoryService() 36 { 37 var commonCommands = new List<string> 38 { 39 "com.microsoft.cmdpal.shell", 40 "com.microsoft.cmdpal.windowwalker", 41 "Visual Studio 2022 Preview_6533433915015224980", 42 "com.microsoft.cmdpal.reload", 43 "com.microsoft.cmdpal.shell", 44 }; 45 46 return CreateHistory(commonCommands); 47 } 48 49 [TestMethod] 50 public void ValidateHistoryFunctionality() 51 { 52 // Setup 53 var history = CreateHistory(); 54 55 // Act 56 history.AddHistoryItem("com.microsoft.cmdpal.shell"); 57 58 // Assert 59 Assert.IsTrue(history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell") > 0); 60 } 61 62 [TestMethod] 63 public void ValidateHistoryWeighting() 64 { 65 // Setup 66 var history = CreateBasicHistoryService(); 67 68 // Act 69 var shellWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.shell"); 70 var windowWalkerWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.windowwalker"); 71 var vsWeight = history.GetCommandHistoryWeight("Visual Studio 2022 Preview_6533433915015224980"); 72 var reloadWeight = history.GetCommandHistoryWeight("com.microsoft.cmdpal.reload"); 73 var nonExistentWeight = history.GetCommandHistoryWeight("non.existent.command"); 74 75 // Assert 76 Assert.IsTrue(shellWeight > windowWalkerWeight, "Shell should be weighted higher than Window Walker, more uses"); 77 Assert.IsTrue(vsWeight > windowWalkerWeight, "Visual Studio should be weighted higher than Window Walker, because recency"); 78 Assert.AreEqual(reloadWeight, vsWeight, "both reload and VS were used in the last three commands, same weight"); 79 Assert.IsTrue(shellWeight > vsWeight, "VS and run were both used in the last 3, but shell has 2 more frequency"); 80 Assert.AreEqual(0, nonExistentWeight, "Nonexistent command should have zero weight"); 81 } 82 83 private sealed partial record ListItemMock( 84 string Title, 85 string? Subtitle = "", 86 string? GivenId = "", 87 string? ProviderId = "") : IListItem 88 { 89 public string Id => string.IsNullOrEmpty(GivenId) ? GenerateId() : GivenId; 90 91 public IDetails Details => throw new System.NotImplementedException(); 92 93 public string Section => throw new System.NotImplementedException(); 94 95 public ITag[] Tags => throw new System.NotImplementedException(); 96 97 public string TextToSuggest => throw new System.NotImplementedException(); 98 99 public ICommand Command => new NoOpCommand() { Id = Id }; 100 101 public IIconInfo Icon => throw new System.NotImplementedException(); 102 103 public IContextItem[] MoreCommands => throw new System.NotImplementedException(); 104 105 #pragma warning disable CS0067 106 public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged; 107 #pragma warning restore CS0067 108 109 private string GenerateId() 110 { 111 // Use WyHash64 to generate stable ID hashes. 112 // manually seeding with 0, so that the hash is stable across launches 113 var result = WyHash64.ComputeHash64(ProviderId + Title + Subtitle, seed: 0); 114 return $"{ProviderId}{result}"; 115 } 116 } 117 118 private static RecentCommandsManager CreateHistory(IList<ListItemMock> items) 119 { 120 var history = new RecentCommandsManager(); 121 foreach (var item in items) 122 { 123 history.AddHistoryItem(item.Id); 124 } 125 126 return history; 127 } 128 129 [TestMethod] 130 public void ValidateMocksWork() 131 { 132 // Setup 133 var items = new List<ListItemMock> 134 { 135 new("Command A", "Subtitle A", "idA", "providerA"), 136 new("Command B", "Subtitle B", GivenId: "idB"), 137 new("Command C", "Subtitle C", ProviderId: "providerC"), 138 new("Command A", "Subtitle A", "idA", "providerA"), // Duplicate to test incrementing uses 139 }; 140 141 // Act 142 var history = CreateHistory(items); 143 144 // Assert 145 foreach (var item in items) 146 { 147 var weight = history.GetCommandHistoryWeight(item.Id); 148 Assert.IsTrue(weight > 0, $"Item {item.Title} should have a weight greater than zero."); 149 } 150 151 // Check that the duplicate item has a higher weight due to increased uses 152 var weightA = history.GetCommandHistoryWeight("idA"); 153 var weightB = history.GetCommandHistoryWeight("idB"); 154 var weightC = history.GetCommandHistoryWeight(items[2].Id); // providerC generated ID 155 Assert.IsTrue(weightA > weightB, "Item A should have a higher weight than Item B due to more uses."); 156 Assert.IsTrue(weightA > weightC, "Item A should have a higher weight than Item C due to more uses."); 157 Assert.AreEqual(weightC, weightB, "Item C and Item B were used in the last 3 commands"); 158 } 159 160 [TestMethod] 161 public void ValidateHistoryBuckets() 162 { 163 // Setup 164 // (these will be checked in reverse order, so that A is the most recent) 165 var items = new List<ListItemMock> 166 { 167 new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 168 new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 169 new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 170 new("Command D", "Subtitle D", GivenId: "idD"), // #3 -> bucket 1 171 new("Command E", "Subtitle E", GivenId: "idE"), // #4 -> bucket 1 172 new("Command F", "Subtitle F", GivenId: "idF"), // #5 -> bucket 1 173 new("Command G", "Subtitle G", GivenId: "idG"), // #6 -> bucket 1 174 new("Command H", "Subtitle H", GivenId: "idH"), // #7 -> bucket 1 175 new("Command I", "Subtitle I", GivenId: "idI"), // #8 -> bucket 1 176 new("Command J", "Subtitle J", GivenId: "idJ"), // #9 -> bucket 1 177 new("Command K", "Subtitle K", GivenId: "idK"), // #10 -> bucket 1 178 new("Command L", "Subtitle L", GivenId: "idL"), // #11 -> bucket 2 179 new("Command M", "Subtitle M", GivenId: "idM"), // #12 -> bucket 2 180 new("Command N", "Subtitle N", GivenId: "idN"), // #13 -> bucket 2 181 new("Command O", "Subtitle O", GivenId: "idO"), // #14 -> bucket 2 182 }; 183 184 for (var i = items.Count; i <= 50; i++) 185 { 186 items.Add(new ListItemMock($"Command #{i}", GivenId: $"id{i}")); 187 } 188 189 // Act 190 var history = CreateHistory(items.Reverse<ListItemMock>().ToList()); 191 192 // Assert 193 // First three items should be in the top bucket 194 var weightA = history.GetCommandHistoryWeight("idA"); 195 var weightB = history.GetCommandHistoryWeight("idB"); 196 var weightC = history.GetCommandHistoryWeight("idC"); 197 198 Assert.AreEqual(weightA, weightB, "Items A and B were used in the last 3 commands"); 199 Assert.AreEqual(weightB, weightC, "Items B and C were used in the last 3 commands"); 200 201 // Next eight items (3-10 inclusive) should be in the second bucket 202 var weightD = history.GetCommandHistoryWeight("idD"); 203 var weightE = history.GetCommandHistoryWeight("idE"); 204 var weightF = history.GetCommandHistoryWeight("idF"); 205 var weightG = history.GetCommandHistoryWeight("idG"); 206 var weightH = history.GetCommandHistoryWeight("idH"); 207 var weightI = history.GetCommandHistoryWeight("idI"); 208 var weightJ = history.GetCommandHistoryWeight("idJ"); 209 var weightK = history.GetCommandHistoryWeight("idK"); 210 211 Assert.AreEqual(weightD, weightE, "Items D and E were used in the last 10 commands"); 212 Assert.AreEqual(weightE, weightF, "Items E and F were used in the last 10 commands"); 213 Assert.AreEqual(weightF, weightG, "Items F and G were used in the last 10 commands"); 214 Assert.AreEqual(weightG, weightH, "Items G and H were used in the last 10 commands"); 215 Assert.AreEqual(weightH, weightI, "Items H and I were used in the last 10 commands"); 216 Assert.AreEqual(weightI, weightJ, "Items I and J were used in the last 10 commands"); 217 Assert.AreEqual(weightJ, weightK, "Items J and K were used in the last 10 commands"); 218 219 // Items up to the 15th should be in the third bucket 220 var weightL = history.GetCommandHistoryWeight("idL"); 221 var weightM = history.GetCommandHistoryWeight("idM"); 222 var weightN = history.GetCommandHistoryWeight("idN"); 223 var weightO = history.GetCommandHistoryWeight("idO"); 224 var weight15 = history.GetCommandHistoryWeight("id15"); 225 Assert.AreEqual(weightL, weightM, "Items L and M were used in the last 15 commands"); 226 Assert.AreEqual(weightM, weightN, "Items M and N were used in the last 15 commands"); 227 Assert.AreEqual(weightN, weightO, "Items N and O were used in the last 15 commands"); 228 Assert.AreEqual(weightO, weight15, "Items O and 15 were used in the last 15 commands"); 229 230 // Items after that should be in the lowest buckets 231 var weight0 = history.GetCommandHistoryWeight(items[0].Id); 232 var weight3 = history.GetCommandHistoryWeight(items[3].Id); 233 var weight11 = history.GetCommandHistoryWeight(items[11].Id); 234 var weight16 = history.GetCommandHistoryWeight("id16"); 235 var weight20 = history.GetCommandHistoryWeight("id20"); 236 var weight30 = history.GetCommandHistoryWeight("id30"); 237 var weight40 = history.GetCommandHistoryWeight("id40"); 238 var weight49 = history.GetCommandHistoryWeight("id49"); 239 240 Assert.IsTrue(weight0 > weight3); 241 Assert.IsTrue(weight3 > weight11); 242 Assert.IsTrue(weight11 > weight16); 243 244 Assert.AreEqual(weight16, weight20); 245 Assert.AreEqual(weight20, weight30); 246 Assert.IsTrue(weight30 > weight40); 247 Assert.AreEqual(weight40, weight49); 248 249 // The 50th item has fallen out of the list now 250 var weight50 = history.GetCommandHistoryWeight("id50"); 251 Assert.AreEqual(0, weight50, "Item 50 should have fallen out of the history list"); 252 } 253 254 [TestMethod] 255 public void ValidateSimpleScoring() 256 { 257 // Setup 258 var items = new List<ListItemMock> 259 { 260 new("Command A", "Subtitle A", GivenId: "idA"), // #0 -> bucket 0 261 new("Command B", "Subtitle B", GivenId: "idB"), // #1 -> bucket 0 262 new("Command C", "Subtitle C", GivenId: "idC"), // #2 -> bucket 0 263 }; 264 265 var history = CreateHistory(items.Reverse<ListItemMock>().ToList()); 266 267 var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history); 268 var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history); 269 var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history); 270 271 // Assert 272 // All of these equally match the query, and they're all in the same bucket, 273 // so they should all have the same score. 274 Assert.AreEqual(scoreA, scoreB, "Items A and B should have the same score"); 275 Assert.AreEqual(scoreB, scoreC, "Items B and C should have the same score"); 276 } 277 278 private static List<ListItemMock> CreateMockHistoryItems() 279 { 280 var items = new List<ListItemMock> 281 { 282 new("Visual Studio 2022"), // #0 -> bucket 0 283 new("Visual Studio Code"), // #1 -> bucket 0 284 new("Explore Mastodon", GivenId: "social.mastodon.explore"), // #2 -> bucket 0 285 new("Run commands", Subtitle: "Executes commands (e.g. ping, cmd)", GivenId: "com.microsoft.cmdpal.run"), // #3 -> bucket 1 286 new("Windows Settings"), // #4 -> bucket 1 287 new("Command Prompt"), // #5 -> bucket 1 288 new("Terminal Canary"), // #6 -> bucket 1 289 }; 290 return items; 291 } 292 293 private static RecentCommandsManager CreateMockHistoryService(List<ListItemMock>? items = null) 294 { 295 var history = CreateHistory((items ?? CreateMockHistoryItems()).Reverse<ListItemMock>().ToList()); 296 return history; 297 } 298 299 private sealed record ScoredItem(ListItemMock Item, int Score) 300 { 301 public string Title => Item.Title; 302 303 public override string ToString() => $"[{Score}]{Title}"; 304 } 305 306 private static IEnumerable<ScoredItem> TieScoresToMatches(List<ListItemMock> items, List<int> scores) 307 { 308 if (items.Count != scores.Count) 309 { 310 throw new ArgumentException("Items and scores must have the same number of elements"); 311 } 312 313 for (var i = 0; i < items.Count; i++) 314 { 315 yield return new ScoredItem(items[i], scores[i]); 316 } 317 } 318 319 private static IEnumerable<ScoredItem> GetMatches(IEnumerable<ScoredItem> scoredItems) 320 { 321 var matches = scoredItems 322 .Where(x => x.Score > 0) 323 .OrderByDescending(x => x.Score) 324 .ToList(); 325 326 return matches; 327 } 328 329 private static IEnumerable<ScoredItem> GetMatches(List<ListItemMock> items, List<int> scores) 330 { 331 return GetMatches(TieScoresToMatches(items, scores)); 332 } 333 334 [TestMethod] 335 public void ValidateScoredWeightingSimple() 336 { 337 var items = CreateMockHistoryItems(); 338 var emptyHistory = CreateMockHistoryService(new()); 339 var history = CreateMockHistoryService(items); 340 341 var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList(); 342 var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList(); 343 Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items"); 344 for (var i = 0; i < unweightedScores.Count; i++) 345 { 346 var unweighted = unweightedScores[i]; 347 var weighted = weightedScores[i]; 348 var item = items[i]; 349 if (item.Title.Contains('C', System.StringComparison.CurrentCultureIgnoreCase)) 350 { 351 Assert.IsTrue(unweighted >= 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); 352 Assert.IsTrue(weighted > unweighted, $"Item {item.Title} should have a higher weighted ({weighted}) score than unweighted ({unweighted})"); 353 } 354 else 355 { 356 Assert.AreEqual(unweighted, 0, $"Item {item.Title} didn't match the query, so should have a weighted score of zero"); 357 Assert.AreEqual(unweighted, weighted); 358 } 359 } 360 361 var unweightedMatches = GetMatches(items, unweightedScores).ToList(); 362 Assert.AreEqual(4, unweightedMatches.Count); 363 Assert.AreEqual("Command Prompt", unweightedMatches[0].Title, "Command Prompt should be the top match"); 364 Assert.AreEqual("Visual Studio Code", unweightedMatches[1].Title, "Visual Studio Code should be the second match"); 365 Assert.AreEqual("Terminal Canary", unweightedMatches[2].Title); 366 Assert.AreEqual("Run commands", unweightedMatches[3].Title); 367 368 // Even after weighting for 1 use, Command Prompt should still be the top match. 369 var weightedMatches = GetMatches(items, weightedScores).ToList(); 370 Assert.AreEqual(4, weightedMatches.Count); 371 Assert.AreEqual("Command Prompt", weightedMatches[0].Title); 372 Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title); 373 Assert.AreEqual("Terminal Canary", weightedMatches[2].Title); 374 Assert.AreEqual("Run commands", weightedMatches[3].Title); 375 } 376 377 [TestMethod] 378 public void ValidateTitlesAreMoreImportantThanHistory() 379 { 380 var items = CreateMockHistoryItems(); 381 var emptyHistory = CreateMockHistoryService(new()); 382 var history = CreateMockHistoryService(items); 383 var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList(); 384 var weightedMatches = GetMatches(items, weightedScores).ToList(); 385 386 Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); 387 388 // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches 389 // the title better 390 Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); 391 Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); 392 Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); 393 } 394 395 [TestMethod] 396 public void ValidateTitlesAreMoreImportantThanUsage() 397 { 398 var items = CreateMockHistoryItems(); 399 var emptyHistory = CreateMockHistoryService(new()); 400 var history = CreateMockHistoryService(items); 401 402 // Add extra uses of VS Code to try and push it above Terminal 403 for (var i = 0; i < 10; i++) 404 { 405 history.AddHistoryItem(items[1].Id); 406 } 407 408 var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList(); 409 var weightedMatches = GetMatches(items, weightedScores).ToList(); 410 411 Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands"); 412 413 // Terminal is in bucket 1, VS Code is in bucket 0, but Terminal matches 414 // the title better 415 Assert.AreEqual("Terminal Canary", weightedMatches[0].Title, "Terminal should be the top match, title match"); 416 Assert.AreEqual("Visual Studio Code", weightedMatches[1].Title, "VsCode does fuzzy match, but is less relevant than Terminal"); 417 Assert.AreEqual("Run commands", weightedMatches[2].Title, "run only matches on the subtitle"); 418 } 419 420 [TestMethod] 421 public void ValidateUsageEventuallyHelps() 422 { 423 var items = CreateMockHistoryItems(); 424 var emptyHistory = CreateMockHistoryService(new()); 425 var history = CreateMockHistoryService(items); 426 427 // We're gonna run this test and keep adding more uses of VS Code till 428 // it breaks past Command Prompt 429 var vsCodeId = items[1].Id; 430 for (var i = 0; i < 10; i++) 431 { 432 history.AddHistoryItem(vsCodeId); 433 434 var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList(); 435 var weightedMatches = GetMatches(items, weightedScores).ToList(); 436 Assert.AreEqual(4, weightedMatches.Count); 437 438 var expectedCmdIndex = i < 5 ? 0 : 1; 439 var expectedCodeIndex = i < 5 ? 1 : 0; 440 Assert.AreEqual("Command Prompt", weightedMatches[expectedCmdIndex].Title); 441 Assert.AreEqual("Visual Studio Code", weightedMatches[expectedCodeIndex].Title); 442 } 443 } 444 }