Used System.IO.Abstractions and improved tests

This commit is contained in:
Ani 2024-10-30 16:58:55 +01:00
parent 437bda544f
commit 2b711397f7
8 changed files with 206 additions and 24 deletions

View File

@ -0,0 +1,172 @@
// 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.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using AdvancedPaste.Models.KernelQueryCache;
using AdvancedPaste.Services;
using AdvancedPaste.Settings;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace AdvancedPaste.UnitTests.ServicesTests;
[TestClass]
public sealed class CustomActionKernelQueryCacheServiceTests
{
private static readonly CacheKey CustomActionTestKey = new() { Prompt = "TestPrompt1", AvailableFormats = ClipboardFormat.Text };
private static readonly CacheKey CustomActionTestKey2 = new() { Prompt = "TestPrompt2", AvailableFormats = ClipboardFormat.File | ClipboardFormat.Image };
private static readonly CacheKey MarkdownTestKey = new() { Prompt = "Paste as Markdown", AvailableFormats = ClipboardFormat.Text };
private static readonly CacheKey JSONTestKey = new() { Prompt = "Paste as JSON", AvailableFormats = ClipboardFormat.Text };
private static readonly CacheKey PasteAsTxtFileKey = new() { Prompt = "Paste as .txt file", AvailableFormats = ClipboardFormat.File };
private static readonly CacheKey PasteAsPngFileKey = new() { Prompt = "Paste as .png file", AvailableFormats = ClipboardFormat.Image };
private static readonly CacheValue TestValue = new([new(PasteFormats.PlainText, [])]);
private static readonly CacheValue TestValue2 = new([new(PasteFormats.KernelQuery, new() { { "a", "b" }, { "c", "d" } })]);
private CustomActionKernelQueryCacheService _cacheService;
private Mock<IUserSettings> _userSettings;
private MockFileSystem _fileSystem;
[TestInitialize]
public void TestInitialize()
{
_userSettings = new();
UpdateUserActions([], []);
_fileSystem = new();
_cacheService = new(_userSettings.Object, _fileSystem);
}
[TestMethod]
public async Task Test_Cache_Always_Accepts_Core_Action_Prompt()
{
await AssertAcceptsAsync(MarkdownTestKey);
}
[TestMethod]
public async Task Test_Cache_Accepts_Prompt_When_Custom_Action()
{
await AssertRejectsAsync(CustomActionTestKey);
UpdateUserActions([], [new() { Name = nameof(CustomActionTestKey), Prompt = CustomActionTestKey.Prompt, IsShown = true }]);
await AssertAcceptsAsync(CustomActionTestKey);
await AssertRejectsAsync(CustomActionTestKey2, PasteAsTxtFileKey);
UpdateUserActions([], []);
await AssertRejectsAsync(CustomActionTestKey);
}
[TestMethod]
public async Task Test_Cache_Accepts_Prompt_When_User_Additional_Action()
{
await AssertRejectsAsync(PasteAsTxtFileKey, PasteAsPngFileKey);
UpdateUserActions([PasteFormats.PasteAsHtmlFile, PasteFormats.PasteAsTxtFile], []);
await AssertAcceptsAsync(PasteAsTxtFileKey);
await AssertRejectsAsync(PasteAsPngFileKey, CustomActionTestKey);
UpdateUserActions([], []);
await AssertRejectsAsync(PasteAsTxtFileKey);
}
[TestMethod]
public async Task Test_Cache_Overwrites_Latest_Value()
{
await _cacheService.WriteAsync(JSONTestKey, TestValue);
await _cacheService.WriteAsync(MarkdownTestKey, TestValue2);
await _cacheService.WriteAsync(JSONTestKey, TestValue2);
await _cacheService.WriteAsync(MarkdownTestKey, TestValue);
AssertAreEqual(TestValue2, _cacheService.ReadOrNull(JSONTestKey));
AssertAreEqual(TestValue, _cacheService.ReadOrNull(MarkdownTestKey));
}
[TestMethod]
public async Task Test_Cache_Uses_Case_Insensitive_Prompt_Comparison()
{
static CacheKey CreateUpperCaseKey(CacheKey key) =>
new() { Prompt = key.Prompt.ToUpperInvariant(), AvailableFormats = key.AvailableFormats };
await _cacheService.WriteAsync(CreateUpperCaseKey(JSONTestKey), TestValue);
await _cacheService.WriteAsync(MarkdownTestKey, TestValue2);
AssertAreEqual(TestValue, _cacheService.ReadOrNull(JSONTestKey));
AssertAreEqual(TestValue2, _cacheService.ReadOrNull(MarkdownTestKey));
}
[TestMethod]
public async Task Test_Cache_Uses_Clipboard_Formats_In_Key()
{
CacheKey key1 = new() { Prompt = JSONTestKey.Prompt, AvailableFormats = ClipboardFormat.File };
CacheKey key2 = new() { Prompt = JSONTestKey.Prompt, AvailableFormats = ClipboardFormat.Image };
await _cacheService.WriteAsync(key1, TestValue);
Assert.IsNotNull(_cacheService.ReadOrNull(key1));
Assert.IsNull(_cacheService.ReadOrNull(key2));
}
[TestMethod]
public async Task Test_Cache_Is_Persistent()
{
await _cacheService.WriteAsync(JSONTestKey, TestValue);
await _cacheService.WriteAsync(MarkdownTestKey, TestValue2);
_cacheService = new(_userSettings.Object, _fileSystem); // recreate using same mock file-system to simulate app restart
AssertAreEqual(TestValue, _cacheService.ReadOrNull(JSONTestKey));
AssertAreEqual(TestValue2, _cacheService.ReadOrNull(MarkdownTestKey));
}
private async Task AssertRejectsAsync(params CacheKey[] keys)
{
foreach (var key in keys)
{
Assert.IsNull(_cacheService.ReadOrNull(key));
await _cacheService.WriteAsync(key, TestValue);
Assert.IsNull(_cacheService.ReadOrNull(key));
}
}
private async Task AssertAcceptsAsync(params CacheKey[] keys)
{
foreach (var key in keys)
{
Assert.IsNull(_cacheService.ReadOrNull(key));
await _cacheService.WriteAsync(key, TestValue);
AssertAreEqual(TestValue, _cacheService.ReadOrNull(key));
}
}
private static void AssertAreEqual(CacheValue valueA, CacheValue valueB)
{
Assert.IsNotNull(valueA);
Assert.IsNotNull(valueB);
Assert.AreEqual(valueA.ActionChain.Count, valueB.ActionChain.Count);
foreach (var (itemA, itemB) in valueA.ActionChain.Zip(valueB.ActionChain))
{
Assert.AreEqual(itemA.Format, itemB.Format);
Assert.AreEqual(itemA.Arguments.Count, itemB.Arguments.Count);
Assert.IsFalse(itemA.Arguments.Except(itemB.Arguments).Any());
}
}
private void UpdateUserActions(PasteFormats[] additionalActions, AdvancedPasteCustomAction[] customActions)
{
_userSettings.Setup(settingsObj => settingsObj.AdditionalActions).Returns(additionalActions);
_userSettings.Setup(settingsObj => settingsObj.CustomActions).Returns(customActions);
_userSettings.Raise(settingsObj => settingsObj.Changed += null, EventArgs.Empty);
}
}

View File

@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO.Abstractions;
using System.Linq;
using System.Reflection;
using System.Threading;
@ -74,6 +75,7 @@ namespace AdvancedPaste
Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder().UseContentRoot(AppContext.BaseDirectory).ConfigureServices((context, services) =>
{
services.AddSingleton<IFileSystem, FileSystem>();
services.AddSingleton<IUserSettings, UserSettings>();
services.AddSingleton<IAICredentialsProvider, Services.OpenAI.VaultCredentialsProvider>();
services.AddSingleton<ICustomTextTransformService, Services.OpenAI.CustomTextTransformService>();

View File

@ -43,9 +43,9 @@ namespace AdvancedPaste.Settings
public IReadOnlyList<AdvancedPasteCustomAction> CustomActions => _customActions;
public UserSettings()
public UserSettings(IFileSystem fileSystem)
{
_settingsUtils = new SettingsUtils();
_settingsUtils = new SettingsUtils(fileSystem);
IsAdvancedAIEnabled = false;
ShowCustomPreview = true;
@ -56,7 +56,7 @@ namespace AdvancedPaste.Settings
LoadSettingsFromJson();
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged);
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
}
private void OnSettingsFileChanged()

View File

@ -6,4 +6,4 @@ using System.Collections.Generic;
namespace AdvancedPaste.Models;
public record class ActionChainItem(PasteFormats Format, Dictionary<string, object> Arguments);
public record class ActionChainItem(PasteFormats Format, Dictionary<string, string> Arguments);

View File

@ -4,7 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@ -22,17 +22,21 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi
{
private const string PersistedCacheFileName = "kernelQueryCache.json";
private readonly SettingsUtils _settingsUtil = new();
private readonly HashSet<string> _cacheablePrompts = new(CacheKey.PromptComparer);
private readonly Dictionary<CacheKey, CacheValue> _memoryCache = [];
private readonly IUserSettings _userSettings;
private HashSet<string> _cacheablePrompts = [];
private readonly IUserSettings _userSettings;
private readonly IFileSystem _fileSystem;
private readonly SettingsUtils _settingsUtil;
private static string Version => Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString() ?? string.Empty;
public CustomActionKernelQueryCacheService(IUserSettings userSettings)
public CustomActionKernelQueryCacheService(IUserSettings userSettings, IFileSystem fileSystem)
{
_userSettings = userSettings;
_fileSystem = fileSystem;
_settingsUtil = new SettingsUtils(fileSystem);
_userSettings.Changed += OnUserSettingsChanged;
RefreshCacheablePrompts();
@ -66,7 +70,7 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi
return [];
}
var jsonString = File.ReadAllText(_settingsUtil.GetSettingsFilePath(AdvancedPasteSettings.ModuleName, PersistedCacheFileName));
var jsonString = _fileSystem.File.ReadAllText(_settingsUtil.GetSettingsFilePath(AdvancedPasteSettings.ModuleName, PersistedCacheFileName));
var persistedCache = PersistedCache.FromJsonString(jsonString);
if (persistedCache.Version == Version)
@ -98,16 +102,19 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi
private void RefreshCacheablePrompts()
{
var localizedActionNames = from metadata in PasteFormat.MetadataDict.Values
var localizedActionNames = from pair in PasteFormat.MetadataDict
let format = pair.Key
let metadata = pair.Value
where !string.IsNullOrEmpty(metadata.ResourceId)
where metadata.IsCoreAction || _userSettings.AdditionalActions.Contains(format)
select ResourceLoaderInstance.ResourceLoader.GetString(metadata.ResourceId);
var customActionPrompts = from customAction in _userSettings.CustomActions
select customAction.Prompt;
// Only cache queries with these prompts to prevent the cache from getting too large and to avoid potential privacy issues.
_cacheablePrompts = localizedActionNames.Concat(customActionPrompts)
.ToHashSet(CacheKey.PromptComparer);
_cacheablePrompts.Clear();
_cacheablePrompts.UnionWith(localizedActionNames.Concat(customActionPrompts));
}
private bool RemoveInapplicableCacheKeys()
@ -134,7 +141,7 @@ public sealed class CustomActionKernelQueryCacheService : IKernelQueryCacheServi
_settingsUtil.SaveSettings(cache.ToJsonString(), AdvancedPasteSettings.ModuleName, PersistedCacheFileName);
Logger.LogDebug($"Kernel query cache saved with {_memoryCache.Count} items");
Logger.LogDebug($"Kernel query cache saved with {_memoryCache.Count} item(s)");
await Task.CompletedTask; // Async placeholder until _settingsUtil.SaveSettings has an async implementation
}

View File

@ -116,7 +116,7 @@ public abstract class KernelServiceBase(IKernelQueryCacheService queryCacheServi
{
if (item.Arguments.Count > 0)
{
await ExecutePromptTransformAsync(kernel, item.Format, (string)item.Arguments[PromptParameterName]);
await ExecutePromptTransformAsync(kernel, item.Format, item.Arguments[PromptParameterName]);
}
else
{

View File

@ -6,7 +6,7 @@ using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
@ -85,7 +85,7 @@ namespace AdvancedPaste.ViewModels
public event EventHandler PreviewRequested;
public OptionsViewModel(IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider aiCredentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
{
_aiCredentialsProvider = aiCredentialsProvider;
_userSettings = userSettings;
@ -119,7 +119,7 @@ namespace AdvancedPaste.ViewModels
try
{
// Delete file that is no longer needed but might have been written by previous version and contain sensitive information.
File.Delete(new SettingsUtils().GetSettingsFilePath(Constants.AdvancedPasteModuleName, "lastQuery.json"));
fileSystem.File.Delete(new SettingsUtils(fileSystem).GetSettingsFilePath(Constants.AdvancedPasteModuleName, "lastQuery.json"));
}
catch
{

View File

@ -7,7 +7,6 @@ using System.Diagnostics;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Net.NetworkInformation;
using System.Security.Principal;
using Microsoft.PowerToys.Settings.UI.Library.CustomAction;
@ -54,16 +53,18 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Utilities
return sendCustomAction.ToJsonString();
}
public static IFileSystemWatcher GetFileWatcher(string moduleName, string fileName, Action onChangedCallback)
public static IFileSystemWatcher GetFileWatcher(string moduleName, string fileName, Action onChangedCallback, IFileSystem fileSystem = null)
{
var path = FileSystem.Path.Combine(LocalApplicationDataFolder(), $"Microsoft\\PowerToys\\{moduleName}");
fileSystem ??= FileSystem;
if (!FileSystem.Directory.Exists(path))
var path = fileSystem.Path.Combine(LocalApplicationDataFolder(), $"Microsoft\\PowerToys\\{moduleName}");
if (!fileSystem.Directory.Exists(path))
{
FileSystem.Directory.CreateDirectory(path);
fileSystem.Directory.CreateDirectory(path);
}
var watcher = FileSystem.FileSystemWatcher.CreateNew();
var watcher = fileSystem.FileSystemWatcher.CreateNew();
watcher.Path = path;
watcher.Filter = fileName;
watcher.NotifyFilter = NotifyFilters.LastWrite;