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  }