diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 256e461..430a6cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,21 +84,28 @@ jobs: /o:"${{ vars.SONAR_ORGANIZATION }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - /d:sonar.cs.opencover.reportsPaths="${{ github.workspace }}/coverage/**/coverage.opencover.xml" - /d:sonar.coverage.exclusions="Tests/**" + /d:sonar.cs.opencover.reportsPaths="coverage/core/**/coverage.opencover.xml,coverage/listener/**/coverage.opencover.xml,coverage/ui/**/coverage.opencover.xml" + /d:sonar.dotnet.excludeTestProjects=true + /d:sonar.exclusions="**/obj/**,**/bin/**,**/AssemblyInfo.cs,**/GlobalSuppressions.cs,**/*.xaml,**/Assets/**,**/Localization/Languages/**,CopyPaste.Launcher/**" + /d:sonar.coverage.exclusions="**/AssemblyInfo.cs,**/GlobalSuppressions.cs,**/App.xaml.cs,**/*Window.xaml.cs,**/*Window.cs,**/*Panel.cs,**/Win32WindowHelper.cs,**/HotkeyHelper.cs,**/ClipboardFilterChipsHelper.cs,**/WindowsClipboardListener.cs,**/ClipboardHelper.cs,**/CopyPasteEngine.cs,**/FocusHelper.cs,**/WindowsThumbnailExtractor.cs" - name: Restore dependencies run: dotnet restore -p:Platform=x64 - name: Build Solution - run: dotnet build CopyPaste.slnx -c Debug -p:Platform=x64 --no-restore + run: dotnet build CopyPaste.slnx -c Debug -p:Platform=x64 -p:DeterministicSourcePaths=false --no-restore - name: Run Tests with Coverage - run: > - dotnet test CopyPaste.slnx -c Debug -p:Platform=x64 --no-build - --collect:"XPlat Code Coverage" - --results-directory coverage - --settings coverage.runsettings + run: | + dotnet test Tests/CopyPaste.Core.Tests/ -c Debug -p:Platform=x64 --no-build ` + --collect:"XPlat Code Coverage" --results-directory coverage/core ` + --settings coverage.core.runsettings + dotnet test Tests/CopyPaste.Listener.Tests/ -c Debug -p:Platform=x64 --no-build ` + --collect:"XPlat Code Coverage" --results-directory coverage/listener ` + --settings coverage.listener.runsettings + dotnet test Tests/CopyPaste.UI.Tests/ -c Debug -p:Platform=x64 --no-build ` + --collect:"XPlat Code Coverage" --results-directory coverage/ui ` + --settings coverage.ui.runsettings - name: SonarScanner End env: diff --git a/CopyPaste.Core/ClipboardHelper.cs b/CopyPaste.Core/ClipboardHelper.cs index bd42083..a4d54e2 100644 --- a/CopyPaste.Core/ClipboardHelper.cs +++ b/CopyPaste.Core/ClipboardHelper.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using Windows.ApplicationModel.DataTransfer; using Windows.Storage; @@ -5,6 +6,7 @@ namespace CopyPaste.Core; +[ExcludeFromCodeCoverage(Justification = "Requires Windows Clipboard runtime (WinRT) — not unit testable")] public static class ClipboardHelper { public static bool SetClipboardContent(ClipboardItem item, bool plainText = false) diff --git a/CopyPaste.Core/CopyPasteEngine.cs b/CopyPaste.Core/CopyPasteEngine.cs index a5a604d..adfd636 100644 --- a/CopyPaste.Core/CopyPasteEngine.cs +++ b/CopyPaste.Core/CopyPasteEngine.cs @@ -1,5 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + namespace CopyPaste.Core; +[ExcludeFromCodeCoverage(Justification = "Orchestrates Win32 clipboard listener — requires running system")] public sealed class CopyPasteEngine : IDisposable { private readonly SqliteRepository _repository; diff --git a/CopyPaste.Core/FocusHelper.cs b/CopyPaste.Core/FocusHelper.cs index ff4cfec..4fbfdfb 100644 --- a/CopyPaste.Core/FocusHelper.cs +++ b/CopyPaste.Core/FocusHelper.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; namespace CopyPaste.Core; -[System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5392:Use DefaultDllImportSearchPaths attribute for P/Invokes", +[SuppressMessage("Security", "CA5392:Use DefaultDllImportSearchPaths attribute for P/Invokes", Justification = "P/Invokes target well-known system DLLs (user32.dll, kernel32.dll)")] +[ExcludeFromCodeCoverage(Justification = "Win32 P/Invoke (user32.dll SendInput/SetForegroundWindow) — not unit testable")] public static partial class FocusHelper { #region P/Invoke Declarations diff --git a/CopyPaste.Core/WindowsThumbnailExtractor.cs b/CopyPaste.Core/WindowsThumbnailExtractor.cs index 38b362c..322993f 100644 --- a/CopyPaste.Core/WindowsThumbnailExtractor.cs +++ b/CopyPaste.Core/WindowsThumbnailExtractor.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using SkiaSharp; using System.Runtime.InteropServices; namespace CopyPaste.Core; +[ExcludeFromCodeCoverage(Justification = "Windows Shell IShellItemImageFactory P/Invoke — not unit testable")] public static partial class WindowsThumbnailExtractor { private static readonly Guid _iShellItemImageFactoryGuid = new("bcc18b79-ba16-442f-80c4-8a59c30c463b"); diff --git a/CopyPaste.Listener/WindowsClipboardListener.cs b/CopyPaste.Listener/WindowsClipboardListener.cs index 1af2e0d..ddc3121 100644 --- a/CopyPaste.Listener/WindowsClipboardListener.cs +++ b/CopyPaste.Listener/WindowsClipboardListener.cs @@ -1,6 +1,7 @@ using CopyPaste.Core; using System.Collections.ObjectModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.RegularExpressions; @@ -8,6 +9,7 @@ namespace CopyPaste.Listener; +[ExcludeFromCodeCoverage(Justification = "Win32 message loop and Windows clipboard API — requires running Windows message pump")] public sealed partial class WindowsClipboardListener(IClipboardService service) : IClipboardListener { private const uint _cF_UNICODETEXT = 13; diff --git a/CopyPaste.UI/App.xaml.cs b/CopyPaste.UI/App.xaml.cs index 91b980a..0a8161f 100644 --- a/CopyPaste.UI/App.xaml.cs +++ b/CopyPaste.UI/App.xaml.cs @@ -25,11 +25,13 @@ using Microsoft.UI.Xaml; using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Threading; namespace CopyPaste.UI; +[ExcludeFromCodeCoverage(Justification = "WinUI3 Application entry point with Win32 lifecycle — not unit testable")] public sealed partial class App : Application, IDisposable { private const string _launcherReadyEventName = "CopyPaste_AppReady"; diff --git a/CopyPaste.UI/Helpers/ClipboardFilterChipsHelper.cs b/CopyPaste.UI/Helpers/ClipboardFilterChipsHelper.cs new file mode 100644 index 0000000..6105c0e --- /dev/null +++ b/CopyPaste.UI/Helpers/ClipboardFilterChipsHelper.cs @@ -0,0 +1,139 @@ +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Shapes; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Windows.UI; + +namespace CopyPaste.UI.Helpers; + +[ExcludeFromCodeCoverage(Justification = "Manipulates WinUI3 UI controls directly — requires WinUI3 runtime")] +internal static class ClipboardFilterChipsHelper +{ + internal static void SyncFilterChipsState(ClipboardThemeViewModelBase vm, FrameworkElement root) + { + SetChecked(root, "ColorCheckRed", vm.IsColorSelected(CardColor.Red)); + SetChecked(root, "ColorCheckGreen", vm.IsColorSelected(CardColor.Green)); + SetChecked(root, "ColorCheckPurple", vm.IsColorSelected(CardColor.Purple)); + SetChecked(root, "ColorCheckYellow", vm.IsColorSelected(CardColor.Yellow)); + SetChecked(root, "ColorCheckBlue", vm.IsColorSelected(CardColor.Blue)); + SetChecked(root, "ColorCheckOrange", vm.IsColorSelected(CardColor.Orange)); + + SetChecked(root, "TypeCheckText", vm.IsTypeSelected(ClipboardContentType.Text)); + SetChecked(root, "TypeCheckImage", vm.IsTypeSelected(ClipboardContentType.Image)); + SetChecked(root, "TypeCheckFile", vm.IsTypeSelected(ClipboardContentType.File)); + SetChecked(root, "TypeCheckFolder", vm.IsTypeSelected(ClipboardContentType.Folder)); + SetChecked(root, "TypeCheckLink", vm.IsTypeSelected(ClipboardContentType.Link)); + SetChecked(root, "TypeCheckAudio", vm.IsTypeSelected(ClipboardContentType.Audio)); + SetChecked(root, "TypeCheckVideo", vm.IsTypeSelected(ClipboardContentType.Video)); + + SetChecked(root, "FilterModeContent", vm.ActiveFilterMode == 0); + SetChecked(root, "FilterModeCategory", vm.ActiveFilterMode == 1); + SetChecked(root, "FilterModeType", vm.ActiveFilterMode == 2); + + UpdateSelectedColorsDisplay(vm, root); + UpdateSelectedTypesDisplay(vm, root); + } + + internal static void UpdateSelectedColorsDisplay(ClipboardThemeViewModelBase vm, FrameworkElement root) + { + if (root.FindName("SelectedColorsPanel") is not Panel panel) return; + if (root.FindName("ColorPlaceholder") is not UIElement placeholder) return; + + while (panel.Children.Count > 1) + panel.Children.RemoveAt(1); + + var selectedColors = new List<(CardColor color, string hex)>(); + if (vm.IsColorSelected(CardColor.Red)) selectedColors.Add((CardColor.Red, "#E74C3C")); + if (vm.IsColorSelected(CardColor.Green)) selectedColors.Add((CardColor.Green, "#2ECC71")); + if (vm.IsColorSelected(CardColor.Purple)) selectedColors.Add((CardColor.Purple, "#9B59B6")); + if (vm.IsColorSelected(CardColor.Yellow)) selectedColors.Add((CardColor.Yellow, "#F1C40F")); + if (vm.IsColorSelected(CardColor.Blue)) selectedColors.Add((CardColor.Blue, "#3498DB")); + if (vm.IsColorSelected(CardColor.Orange)) selectedColors.Add((CardColor.Orange, "#E67E22")); + + if (selectedColors.Count == 0) + { + placeholder.Visibility = Visibility.Visible; + } + else + { + placeholder.Visibility = Visibility.Collapsed; + foreach (var (_, hex) in selectedColors) + { + var chip = new Ellipse + { + Width = 16, + Height = 16, + Fill = new SolidColorBrush(ClipboardWindowHelpers.ParseColor(hex)), + Stroke = new SolidColorBrush(Color.FromArgb(40, 0, 0, 0)), + StrokeThickness = 1, + Margin = new Thickness(0, 0, 2, 0) + }; + panel.Children.Add(chip); + } + } + } + + internal static void UpdateSelectedTypesDisplay(ClipboardThemeViewModelBase vm, FrameworkElement root) + { + if (root.FindName("SelectedTypesPanel") is not Panel panel) return; + if (root.FindName("TypePlaceholder") is not UIElement placeholder) return; + + while (panel.Children.Count > 1) + panel.Children.RemoveAt(1); + + var selectedTypes = new List<(ClipboardContentType type, string glyph)>(); + if (vm.IsTypeSelected(ClipboardContentType.Text)) selectedTypes.Add((ClipboardContentType.Text, "\uE8C1")); + if (vm.IsTypeSelected(ClipboardContentType.Image)) selectedTypes.Add((ClipboardContentType.Image, "\uE91B")); + if (vm.IsTypeSelected(ClipboardContentType.File)) selectedTypes.Add((ClipboardContentType.File, "\uE7C3")); + if (vm.IsTypeSelected(ClipboardContentType.Folder)) selectedTypes.Add((ClipboardContentType.Folder, "\uE8B7")); + if (vm.IsTypeSelected(ClipboardContentType.Link)) selectedTypes.Add((ClipboardContentType.Link, "\uE71B")); + if (vm.IsTypeSelected(ClipboardContentType.Audio)) selectedTypes.Add((ClipboardContentType.Audio, "\uE8D6")); + if (vm.IsTypeSelected(ClipboardContentType.Video)) selectedTypes.Add((ClipboardContentType.Video, "\uE714")); + + if (selectedTypes.Count == 0) + { + placeholder.Visibility = Visibility.Visible; + } + else + { + placeholder.Visibility = Visibility.Collapsed; + var maxToShow = 5; + var shown = 0; + foreach (var (_, glyph) in selectedTypes) + { + if (shown >= maxToShow) + { + var moreText = new TextBlock + { + Text = $"+{selectedTypes.Count - maxToShow}", + FontSize = 10, + Opacity = 0.6, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(4, 0, 0, 0) + }; + panel.Children.Add(moreText); + break; + } + var chipBorder = new Border + { + Background = new SolidColorBrush(Color.FromArgb(30, 128, 128, 128)), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 3, 6, 3), + Child = new FontIcon { Glyph = glyph, FontSize = 12 } + }; + panel.Children.Add(chipBorder); + shown++; + } + } + } + + private static void SetChecked(FrameworkElement root, string name, bool value) + { + if (root.FindName(name) is CheckBox cb) cb.IsChecked = value; + else if (root.FindName(name) is RadioMenuFlyoutItem item) item.IsChecked = value; + } +} diff --git a/CopyPaste.UI/Helpers/ClipboardWindowHelpers.cs b/CopyPaste.UI/Helpers/ClipboardWindowHelpers.cs new file mode 100644 index 0000000..041b24e --- /dev/null +++ b/CopyPaste.UI/Helpers/ClipboardWindowHelpers.cs @@ -0,0 +1,189 @@ +using CopyPaste.Core; +using CopyPaste.UI.Localization; +using CopyPaste.UI.Themes; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using System; +using System.Globalization; +using System.Threading.Tasks; +using Windows.UI; + +namespace CopyPaste.UI.Helpers; + +internal static class ClipboardWindowHelpers +{ + internal static ScrollViewer? FindScrollViewer(DependencyObject parent) + { + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is ScrollViewer sv) + return sv; + + var result = FindScrollViewer(child); + if (result != null) + return result; + } + return null; + } + + internal static DependencyObject? FindDescendant(DependencyObject parent, string name) + { + if (parent is FrameworkElement fe && fe.Name == name) + return parent; + + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + var result = FindDescendant(child, name); + if (result != null) + return result; + } + return null; + } + + internal static T? FindChild(DependencyObject parent, string name) where T : FrameworkElement + { + for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) + { + var child = VisualTreeHelper.GetChild(parent, i); + if (child is T typedChild && typedChild.Name == name) + return typedChild; + var result = FindChild(child, name); + if (result != null) + return result; + } + return null; + } + + internal static Color ParseColor(string hex) + { + hex = hex.TrimStart('#'); + return Color.FromArgb( + 255, + byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), + byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) + ); + } + + internal static void LoadImageSource(Image image, string? imagePath, int thumbnailDecodeHeight) + { + if (string.IsNullOrEmpty(imagePath)) return; + + if (!imagePath.StartsWith("ms-appx://", StringComparison.OrdinalIgnoreCase) && + !System.IO.File.Exists(imagePath)) + { + if (imagePath.Contains("_t.", StringComparison.Ordinal)) + { + try + { + image.Source = new BitmapImage + { + UriSource = new Uri("ms-appx:///Assets/thumb/image.png") + }; + } + catch { /* Silently fail */ } + } + return; + } + + if (image.Source is BitmapImage currentBitmap) + { + var currentPath = currentBitmap.UriSource?.LocalPath; + if (currentPath != null && imagePath.EndsWith(System.IO.Path.GetFileName(currentPath), StringComparison.OrdinalIgnoreCase)) + return; + } + + try + { + image.Source = new BitmapImage + { + UriSource = new Uri(imagePath), + CreateOptions = BitmapCreateOptions.None, + DecodePixelHeight = thumbnailDecodeHeight + }; + } + catch { /* Silently fail */ } + } + + internal static void SetWindowIcon(AppWindow appWindow) + { + var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "CopyPasteLogoSimple.ico"); + if (System.IO.File.Exists(iconPath)) + appWindow.SetIcon(iconPath); + } + + internal static async Task<(string? label, CardColor color)?> ShowEditDialogAsync( + XamlRoot xamlRoot, + ClipboardItemViewModel itemVM, + Func? colorLabelResolver = null) + { + colorLabelResolver ??= (_, key) => L.Get(key); + + var labelBox = new TextBox + { + Text = itemVM.Label ?? string.Empty, + PlaceholderText = L.Get("clipboard.editDialog.labelPlaceholder"), + MaxLength = ClipboardItem.MaxLabelLength, + HorizontalAlignment = HorizontalAlignment.Stretch + }; + + var colorPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 12, 0, 0) }; + var colorLabel = new TextBlock + { + Text = L.Get("clipboard.editDialog.colorLabel"), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 8, 0), + Opacity = 0.7 + }; + colorPanel.Children.Add(colorLabel); + + var colorCombo = new ComboBox { Width = 140 }; + colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorNone"), Tag = CardColor.None }); + colorCombo.Items.Add(new ComboBoxItem { Content = colorLabelResolver("Red", "clipboard.editDialog.colorRed"), Tag = CardColor.Red }); + colorCombo.Items.Add(new ComboBoxItem { Content = colorLabelResolver("Green", "clipboard.editDialog.colorGreen"), Tag = CardColor.Green }); + colorCombo.Items.Add(new ComboBoxItem { Content = colorLabelResolver("Purple", "clipboard.editDialog.colorPurple"), Tag = CardColor.Purple }); + colorCombo.Items.Add(new ComboBoxItem { Content = colorLabelResolver("Yellow", "clipboard.editDialog.colorYellow"), Tag = CardColor.Yellow }); + colorCombo.Items.Add(new ComboBoxItem { Content = colorLabelResolver("Blue", "clipboard.editDialog.colorBlue"), Tag = CardColor.Blue }); + colorCombo.Items.Add(new ComboBoxItem { Content = colorLabelResolver("Orange", "clipboard.editDialog.colorOrange"), Tag = CardColor.Orange }); + + colorCombo.SelectedIndex = (int)itemVM.CardColor; + colorPanel.Children.Add(colorCombo); + + var hintText = new TextBlock + { + Text = L.Get("clipboard.editDialog.labelHint"), + FontSize = 11, + Opacity = 0.5, + Margin = new Thickness(0, 4, 0, 0) + }; + + var contentPanel = new StackPanel { Spacing = 4 }; + contentPanel.Children.Add(labelBox); + contentPanel.Children.Add(hintText); + contentPanel.Children.Add(colorPanel); + + var dialog = new ContentDialog + { + Title = L.Get("clipboard.editDialog.title"), + Content = contentPanel, + PrimaryButtonText = L.Get("clipboard.editDialog.save"), + CloseButtonText = L.Get("clipboard.editDialog.cancel"), + DefaultButton = ContentDialogButton.Primary, + XamlRoot = xamlRoot + }; + + var result = await dialog.ShowAsync(); + if (result == ContentDialogResult.Primary && colorCombo.SelectedItem is ComboBoxItem selectedColor) + { + var label = string.IsNullOrWhiteSpace(labelBox.Text) ? null : labelBox.Text.Trim(); + var color = (CardColor)(selectedColor.Tag ?? CardColor.None); + return (label, color); + } + return null; + } +} diff --git a/CopyPaste.UI/Helpers/HotkeyHelper.cs b/CopyPaste.UI/Helpers/HotkeyHelper.cs index 8888618..092765d 100644 --- a/CopyPaste.UI/Helpers/HotkeyHelper.cs +++ b/CopyPaste.UI/Helpers/HotkeyHelper.cs @@ -1,5 +1,6 @@ using CopyPaste.Core; using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; namespace CopyPaste.UI.Helpers; @@ -7,6 +8,7 @@ namespace CopyPaste.UI.Helpers; /// /// Helper class to handle Windows messages for hotkey support in WinUI 3. /// +[ExcludeFromCodeCoverage(Justification = "Win32 hotkey registration and WndProc — requires running Win32 window")] internal static partial class HotkeyHelper { private const int _wM_HOTKEY = 0x0312; diff --git a/CopyPaste.UI/Helpers/Win32WindowHelper.cs b/CopyPaste.UI/Helpers/Win32WindowHelper.cs index 5324e58..3e0f306 100644 --- a/CopyPaste.UI/Helpers/Win32WindowHelper.cs +++ b/CopyPaste.UI/Helpers/Win32WindowHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; namespace CopyPaste.UI.Helpers; @@ -6,6 +7,7 @@ namespace CopyPaste.UI.Helpers; /// /// Helper class for Win32 window manipulation APIs. /// +[ExcludeFromCodeCoverage(Justification = "Win32 P/Invoke (user32.dll, dwmapi.dll) — not unit testable")] internal static partial class Win32WindowHelper { #region P/Invoke Declarations diff --git a/CopyPaste.UI/Localization/L.cs b/CopyPaste.UI/Localization/L.cs index 85218d6..10ecc01 100644 --- a/CopyPaste.UI/Localization/L.cs +++ b/CopyPaste.UI/Localization/L.cs @@ -9,10 +9,25 @@ public static class L public static void Initialize(LocalizationService service) => _instance = service ?? throw new ArgumentNullException(nameof(service)); - public static string Get(string key, string? defaultValue = null) => - _instance?.Get(key, defaultValue) ?? $"[{key}]"; + public static string Get(string key, string? defaultValue = null) + { + try + { + return _instance?.Get(key, defaultValue) ?? $"[{key}]"; + } + catch (ObjectDisposedException) + { + _instance = null; + return defaultValue ?? $"[{key}]"; + } + } public static string CurrentLanguage => _instance?.CurrentLanguage ?? "en-US"; - internal static void Dispose() => _instance?.Dispose(); + internal static void Dispose() + { + var instance = _instance; + _instance = null; + instance?.Dispose(); + } } diff --git a/CopyPaste.UI/Properties/AssemblyInfo.cs b/CopyPaste.UI/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..0f30278 --- /dev/null +++ b/CopyPaste.UI/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CopyPaste.UI.Tests")] diff --git a/CopyPaste.UI/Shell/ConfigWindow.xaml.cs b/CopyPaste.UI/Shell/ConfigWindow.xaml.cs index b1f36be..eaf33f5 100644 --- a/CopyPaste.UI/Shell/ConfigWindow.xaml.cs +++ b/CopyPaste.UI/Shell/ConfigWindow.xaml.cs @@ -6,11 +6,13 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using Windows.Storage.Pickers; namespace CopyPaste.UI.Shell; +[ExcludeFromCodeCoverage(Justification = "WinUI3 Window with event-driven UI — not unit testable")] public sealed partial class ConfigWindow : Window { private sealed record ThumbnailPreset(int Width, int QualityPng, int QualityJpeg, int GCThreshold, int UIDecodeHeight); diff --git a/CopyPaste.UI/Shell/HelpWindow.xaml.cs b/CopyPaste.UI/Shell/HelpWindow.xaml.cs index 4f46acf..b8a1ac4 100644 --- a/CopyPaste.UI/Shell/HelpWindow.xaml.cs +++ b/CopyPaste.UI/Shell/HelpWindow.xaml.cs @@ -1,9 +1,11 @@ using CopyPaste.UI.Localization; using Microsoft.UI.Xaml; using System; +using System.Diagnostics.CodeAnalysis; namespace CopyPaste.UI.Shell; +[ExcludeFromCodeCoverage(Justification = "WinUI3 Window — not unit testable")] internal sealed partial class HelpWindow : Window { public HelpWindow() diff --git a/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs b/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs index 4bc47b2..a857460 100644 --- a/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs +++ b/CopyPaste.UI/Themes/ClipboardThemeViewModelBase.cs @@ -1,6 +1,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CopyPaste.Core; +using System.Threading.Tasks; using CopyPaste.UI.Helpers; using Microsoft.UI.Dispatching; using Microsoft.UI.Windowing; @@ -308,6 +309,13 @@ public void OnWindowDeactivated() [RelayCommand] private void ClearSearch() => SearchQuery = string.Empty; + [RelayCommand] + private static async Task OpenRepo() + { + var uri = new Uri("https://github.com/rgdevment/CopyPaste/issues"); + await Windows.System.Launcher.LaunchUriAsync(uri); + } + [RelayCommand] public void ShowWindow() { diff --git a/CopyPaste.UI/Themes/Compact/CompactSettingsPanel.cs b/CopyPaste.UI/Themes/Compact/CompactSettingsPanel.cs index 6592d7a..eee4f62 100644 --- a/CopyPaste.UI/Themes/Compact/CompactSettingsPanel.cs +++ b/CopyPaste.UI/Themes/Compact/CompactSettingsPanel.cs @@ -2,9 +2,11 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using System.Diagnostics.CodeAnalysis; namespace CopyPaste.UI.Themes; +[ExcludeFromCodeCoverage(Justification = "Creates WinUI3 UI controls — requires WinUI3 runtime")] internal sealed class CompactSettingsPanel { private NumberBox? _popupWidthBox; diff --git a/CopyPaste.UI/Themes/Compact/CompactTheme.cs b/CopyPaste.UI/Themes/Compact/CompactTheme.cs index 8c278ef..bd95223 100644 --- a/CopyPaste.UI/Themes/Compact/CompactTheme.cs +++ b/CopyPaste.UI/Themes/Compact/CompactTheme.cs @@ -2,10 +2,12 @@ using CopyPaste.UI.Helpers; using Microsoft.UI.Windowing; using System; +using System.Diagnostics.CodeAnalysis; using WinRT.Interop; namespace CopyPaste.UI.Themes; +[ExcludeFromCodeCoverage(Justification = "Creates WinUI3 Window (CompactWindow) — requires WinUI3 runtime")] internal sealed class CompactTheme : ITheme { public string Id => "copypaste.compact"; diff --git a/CopyPaste.UI/Themes/Compact/CompactViewModel.cs b/CopyPaste.UI/Themes/Compact/CompactViewModel.cs index 18f5d63..e52653f 100644 --- a/CopyPaste.UI/Themes/Compact/CompactViewModel.cs +++ b/CopyPaste.UI/Themes/Compact/CompactViewModel.cs @@ -1,7 +1,4 @@ -using CommunityToolkit.Mvvm.Input; using CopyPaste.Core; -using System; -using System.Threading.Tasks; namespace CopyPaste.UI.Themes; @@ -9,11 +6,4 @@ public partial class CompactViewModel(IClipboardService service, MyMConfig confi : ClipboardThemeViewModelBase(service, config, themeSettings.CardMaxLines, themeSettings.CardMinLines) { public override bool IsWindowPinned => themeSettings.PinWindow; - - [RelayCommand] - private static async Task OpenRepo() - { - var uri = new Uri("https://github.com/rgdevment/CopyPaste/issues"); - await Windows.System.Launcher.LaunchUriAsync(uri); - } } diff --git a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs index d9f3480..b23cba6 100644 --- a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs +++ b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml.cs @@ -10,12 +10,14 @@ using Windows.Foundation; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.InteropServices; using WinRT.Interop; namespace CopyPaste.UI.Themes; +[ExcludeFromCodeCoverage(Justification = "WinUI3 Window with event-driven UI — not unit testable")] internal sealed partial class CompactWindow : Window { private readonly AppWindow _appWindow; @@ -59,12 +61,7 @@ public CompactWindow(ThemeContext context, CompactSettings themeSettings) ApplyLocalizedStrings(); } - private void SetWindowIcon() - { - var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "CopyPasteLogoSimple.ico"); - if (System.IO.File.Exists(iconPath)) - _appWindow.SetIcon(iconPath); - } + private void SetWindowIcon() => ClipboardWindowHelpers.SetWindowIcon(_appWindow); private void ConfigurePopupStyle() { @@ -201,7 +198,7 @@ private void Window_Closed(object? _, WindowEventArgs args) ClipboardListView.Loaded -= ClipboardListView_Loaded; SearchBox.KeyDown -= SearchBox_KeyDown; - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); if (scrollViewer != null) scrollViewer.ViewChanged -= ScrollViewer_ViewChanged; @@ -220,7 +217,7 @@ private void Window_Closed(object? _, WindowEventArgs args) private void ClipboardListView_Loaded(object? _, RoutedEventArgs __) { - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); if (scrollViewer != null) scrollViewer.ViewChanged += ScrollViewer_ViewChanged; } @@ -284,8 +281,8 @@ private void TrayMenuSettings_Click(object sender, RoutedEventArgs e) => private void Card_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) { if (sender is not Border border) return; - var timestamp = FindChild(border, "TimestampText"); - var actions = FindChild(border, "HoverActions"); + var timestamp = ClipboardWindowHelpers.FindChild(border, "TimestampText"); + var actions = ClipboardWindowHelpers.FindChild(border, "HoverActions"); if (timestamp != null) timestamp.Opacity = 0; if (actions != null) actions.Opacity = 1; } @@ -293,24 +290,12 @@ private void Card_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerR private void Card_PointerExited(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) { if (sender is not Border border) return; - var timestamp = FindChild(border, "TimestampText"); - var actions = FindChild(border, "HoverActions"); + var timestamp = ClipboardWindowHelpers.FindChild(border, "TimestampText"); + var actions = ClipboardWindowHelpers.FindChild(border, "HoverActions"); if (timestamp != null) timestamp.Opacity = 0.35; if (actions != null) actions.Opacity = 0; } - private static T? FindChild(DependencyObject parent, string name) where T : FrameworkElement - { - for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) - { - var child = VisualTreeHelper.GetChild(parent, i); - if (child is T fe && fe.Name == name) return fe; - var result = FindChild(child, name); - if (result != null) return result; - } - return null; - } - private void ResetFiltersOnShow() { ViewModel.ResetFilters( @@ -377,132 +362,14 @@ private void FilterChips_KeyDown(object _, Microsoft.UI.Xaml.Input.KeyRoutedEven } } - private void SyncFilterChipsState() - { - ColorCheckRed.IsChecked = ViewModel.IsColorSelected(CardColor.Red); - ColorCheckGreen.IsChecked = ViewModel.IsColorSelected(CardColor.Green); - ColorCheckPurple.IsChecked = ViewModel.IsColorSelected(CardColor.Purple); - ColorCheckYellow.IsChecked = ViewModel.IsColorSelected(CardColor.Yellow); - ColorCheckBlue.IsChecked = ViewModel.IsColorSelected(CardColor.Blue); - ColorCheckOrange.IsChecked = ViewModel.IsColorSelected(CardColor.Orange); - - TypeCheckText.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Text); - TypeCheckImage.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Image); - TypeCheckFile.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.File); - TypeCheckFolder.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Folder); - TypeCheckLink.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Link); - TypeCheckAudio.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Audio); - TypeCheckVideo.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Video); - - FilterModeContent.IsChecked = ViewModel.ActiveFilterMode == 0; - FilterModeCategory.IsChecked = ViewModel.ActiveFilterMode == 1; - FilterModeType.IsChecked = ViewModel.ActiveFilterMode == 2; - - UpdateSelectedColorsDisplay(); - UpdateSelectedTypesDisplay(); - } - - private void UpdateSelectedColorsDisplay() - { - while (SelectedColorsPanel.Children.Count > 1) - SelectedColorsPanel.Children.RemoveAt(1); - - var selectedColors = new List<(CardColor color, string hex)>(); - if (ViewModel.IsColorSelected(CardColor.Red)) selectedColors.Add((CardColor.Red, "#E74C3C")); - if (ViewModel.IsColorSelected(CardColor.Green)) selectedColors.Add((CardColor.Green, "#2ECC71")); - if (ViewModel.IsColorSelected(CardColor.Purple)) selectedColors.Add((CardColor.Purple, "#9B59B6")); - if (ViewModel.IsColorSelected(CardColor.Yellow)) selectedColors.Add((CardColor.Yellow, "#F1C40F")); - if (ViewModel.IsColorSelected(CardColor.Blue)) selectedColors.Add((CardColor.Blue, "#3498DB")); - if (ViewModel.IsColorSelected(CardColor.Orange)) selectedColors.Add((CardColor.Orange, "#E67E22")); - - if (selectedColors.Count == 0) - { - ColorPlaceholder.Visibility = Visibility.Visible; - } - else - { - ColorPlaceholder.Visibility = Visibility.Collapsed; - foreach (var (_, hex) in selectedColors) - { - var chip = new Ellipse - { - Width = 16, - Height = 16, - Fill = new SolidColorBrush(ParseColor(hex)), - Stroke = new SolidColorBrush(Windows.UI.Color.FromArgb(40, 0, 0, 0)), - StrokeThickness = 1, - Margin = new Thickness(0, 0, 2, 0) - }; - SelectedColorsPanel.Children.Add(chip); - } - } - } - - private void UpdateSelectedTypesDisplay() - { - while (SelectedTypesPanel.Children.Count > 1) - SelectedTypesPanel.Children.RemoveAt(1); - - var selectedTypes = new List<(ClipboardContentType type, string glyph)>(); - if (ViewModel.IsTypeSelected(ClipboardContentType.Text)) selectedTypes.Add((ClipboardContentType.Text, "\uE8C1")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Image)) selectedTypes.Add((ClipboardContentType.Image, "\uE91B")); - if (ViewModel.IsTypeSelected(ClipboardContentType.File)) selectedTypes.Add((ClipboardContentType.File, "\uE7C3")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Folder)) selectedTypes.Add((ClipboardContentType.Folder, "\uE8B7")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Link)) selectedTypes.Add((ClipboardContentType.Link, "\uE71B")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Audio)) selectedTypes.Add((ClipboardContentType.Audio, "\uE8D6")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Video)) selectedTypes.Add((ClipboardContentType.Video, "\uE714")); + private void SyncFilterChipsState() => + ClipboardFilterChipsHelper.SyncFilterChipsState(ViewModel, (FrameworkElement)Content); - if (selectedTypes.Count == 0) - { - TypePlaceholder.Visibility = Visibility.Visible; - } - else - { - TypePlaceholder.Visibility = Visibility.Collapsed; - var maxToShow = 5; - var shown = 0; - foreach (var (_, glyph) in selectedTypes) - { - if (shown >= maxToShow) - { - var moreText = new TextBlock - { - Text = $"+{selectedTypes.Count - maxToShow}", - FontSize = 10, - Opacity = 0.6, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 0, 0) - }; - SelectedTypesPanel.Children.Add(moreText); - break; - } - var chipBorder = new Border - { - Background = new SolidColorBrush(Windows.UI.Color.FromArgb(30, 128, 128, 128)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 3, 6, 3), - Child = new FontIcon - { - Glyph = glyph, - FontSize = 12 - } - }; - SelectedTypesPanel.Children.Add(chipBorder); - shown++; - } - } - } + private void UpdateSelectedColorsDisplay() => + ClipboardFilterChipsHelper.UpdateSelectedColorsDisplay(ViewModel, (FrameworkElement)Content); - private static Windows.UI.Color ParseColor(string hex) - { - hex = hex.TrimStart('#'); - return Windows.UI.Color.FromArgb( - 255, - byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), - byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture), - byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) - ); - } + private void UpdateSelectedTypesDisplay() => + ClipboardFilterChipsHelper.UpdateSelectedTypesDisplay(ViewModel, (FrameworkElement)Content); private void ClipboardListView_ContainerContentChanging(ListViewBase _, ContainerContentChangingEventArgs args) { @@ -572,45 +439,8 @@ private void LoadClipboardImage(ListViewBase sender, ContainerContentChangingEve } } - private void LoadImageSource(Image image, string? imagePath) - { - if (string.IsNullOrEmpty(imagePath)) return; - - if (!imagePath.StartsWith("ms-appx://", StringComparison.OrdinalIgnoreCase) && - !System.IO.File.Exists(imagePath)) - { - if (imagePath.Contains("_t.", StringComparison.Ordinal)) - { - try - { - image.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage - { - UriSource = new Uri("ms-appx:///Assets/thumb/image.png") - }; - } - catch { /* Silently fail */ } - } - return; - } - - if (image.Source is Microsoft.UI.Xaml.Media.Imaging.BitmapImage currentBitmap) - { - var currentPath = currentBitmap.UriSource?.LocalPath; - if (currentPath != null && imagePath.EndsWith(System.IO.Path.GetFileName(currentPath), StringComparison.OrdinalIgnoreCase)) - return; - } - - try - { - image.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage - { - UriSource = new Uri(imagePath), - CreateOptions = Microsoft.UI.Xaml.Media.Imaging.BitmapCreateOptions.None, - DecodePixelHeight = _config.ThumbnailUIDecodeHeight - }; - } - catch { /* Silently fail */ } - } + private void LoadImageSource(Image image, string? imagePath) => + ClipboardWindowHelpers.LoadImageSource(image, imagePath, _config.ThumbnailUIDecodeHeight); private void SearchBox_TextChanged(object? sender, TextChangedEventArgs _) { @@ -768,26 +598,13 @@ private void ClipboardListView_PreviewKeyDown(object sender, Microsoft.UI.Xaml.I } } - private static ScrollViewer? FindScrollViewer(DependencyObject parent) - { - for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) - { - var child = VisualTreeHelper.GetChild(parent, i); - if (child is ScrollViewer sv) return sv; - - var result = FindScrollViewer(child); - if (result != null) return result; - } - return null; - } - private void FocusSearchBox() => DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () => SearchBox.Focus(FocusState.Programmatic)); private void ResetScrollToTop() { - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); scrollViewer?.ChangeView(null, 0, null, disableAnimation: true); if (ClipboardListView.Items.Count > 0) @@ -800,7 +617,7 @@ private void ViewModel_OnScrollToTopRequested(object? sender, EventArgs e) DispatcherQueue.TryEnqueue(() => { - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); scrollViewer?.ChangeView(null, 0, null, disableAnimation: false); if (ClipboardListView.Items.Count > 0) @@ -811,70 +628,11 @@ private void ViewModel_OnScrollToTopRequested(object? sender, EventArgs e) private async void ShowEditDialog(object? sender, ClipboardItemViewModel itemVM) { _isDialogOpen = true; - try { - var labelBox = new TextBox - { - Text = itemVM.Label ?? string.Empty, - PlaceholderText = L.Get("clipboard.editDialog.labelPlaceholder"), - MaxLength = ClipboardItem.MaxLabelLength, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - - var colorPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 12, 0, 0) }; - var colorLabel = new TextBlock - { - Text = L.Get("clipboard.editDialog.colorLabel"), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - Opacity = 0.7 - }; - colorPanel.Children.Add(colorLabel); - - var colorCombo = new ComboBox { Width = 140 }; - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorNone"), Tag = CardColor.None }); - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorRed"), Tag = CardColor.Red }); - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorGreen"), Tag = CardColor.Green }); - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorPurple"), Tag = CardColor.Purple }); - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorYellow"), Tag = CardColor.Yellow }); - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorBlue"), Tag = CardColor.Blue }); - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorOrange"), Tag = CardColor.Orange }); - - colorCombo.SelectedIndex = (int)itemVM.CardColor; - colorPanel.Children.Add(colorCombo); - - var hintText = new TextBlock - { - Text = L.Get("clipboard.editDialog.labelHint"), - FontSize = 11, - Opacity = 0.5, - Margin = new Thickness(0, 4, 0, 0) - }; - - var contentPanel = new StackPanel { Spacing = 4 }; - contentPanel.Children.Add(labelBox); - contentPanel.Children.Add(hintText); - contentPanel.Children.Add(colorPanel); - - var dialog = new ContentDialog - { - Title = L.Get("clipboard.editDialog.title"), - Content = contentPanel, - PrimaryButtonText = L.Get("clipboard.editDialog.save"), - CloseButtonText = L.Get("clipboard.editDialog.cancel"), - DefaultButton = ContentDialogButton.Primary, - XamlRoot = Content.XamlRoot - }; - - var result = await dialog.ShowAsync(); - - if (result == ContentDialogResult.Primary && colorCombo.SelectedItem is ComboBoxItem selectedColor) - { - var label = string.IsNullOrWhiteSpace(labelBox.Text) ? null : labelBox.Text.Trim(); - var color = (CardColor)(selectedColor.Tag ?? CardColor.None); - ViewModel.SaveItemLabelAndColor(itemVM, label, color); - } + var result = await ClipboardWindowHelpers.ShowEditDialogAsync(Content.XamlRoot, itemVM); + if (result is { } r) + ViewModel.SaveItemLabelAndColor(itemVM, r.label, r.color); } finally { diff --git a/CopyPaste.UI/Themes/Default/DefaultTheme.cs b/CopyPaste.UI/Themes/Default/DefaultTheme.cs index b4fa4b3..62e2690 100644 --- a/CopyPaste.UI/Themes/Default/DefaultTheme.cs +++ b/CopyPaste.UI/Themes/Default/DefaultTheme.cs @@ -2,10 +2,12 @@ using CopyPaste.UI.Helpers; using Microsoft.UI.Windowing; using System; +using System.Diagnostics.CodeAnalysis; using WinRT.Interop; namespace CopyPaste.UI.Themes; +[ExcludeFromCodeCoverage(Justification = "Creates WinUI3 Window (DefaultThemeWindow) — requires WinUI3 runtime")] internal sealed class DefaultTheme : ITheme { public string Id => "copypaste.default"; diff --git a/CopyPaste.UI/Themes/Default/DefaultThemeSettingsPanel.cs b/CopyPaste.UI/Themes/Default/DefaultThemeSettingsPanel.cs index 62c96d5..f744795 100644 --- a/CopyPaste.UI/Themes/Default/DefaultThemeSettingsPanel.cs +++ b/CopyPaste.UI/Themes/Default/DefaultThemeSettingsPanel.cs @@ -2,6 +2,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using System.Diagnostics.CodeAnalysis; namespace CopyPaste.UI.Themes; @@ -9,6 +10,7 @@ namespace CopyPaste.UI.Themes; /// Builds and manages the settings UI for DefaultTheme. /// Embedded in ConfigWindow via ITheme.CreateSettingsSection(). /// +[ExcludeFromCodeCoverage(Justification = "Creates WinUI3 UI controls (NumberBox, etc.) — requires WinUI3 runtime")] internal sealed class DefaultThemeSettingsPanel { // Appearance controls diff --git a/CopyPaste.UI/Themes/Default/DefaultThemeViewModel.cs b/CopyPaste.UI/Themes/Default/DefaultThemeViewModel.cs index c8930d8..3ea998f 100644 --- a/CopyPaste.UI/Themes/Default/DefaultThemeViewModel.cs +++ b/CopyPaste.UI/Themes/Default/DefaultThemeViewModel.cs @@ -1,7 +1,5 @@ using CommunityToolkit.Mvvm.Input; using CopyPaste.Core; -using System; -using System.Threading.Tasks; namespace CopyPaste.UI.Themes; @@ -21,11 +19,4 @@ private void ClearAll() Items.RemoveAt(i); } } - - [RelayCommand] - private static async Task OpenRepo() - { - var uri = new Uri("https://github.com/rgdevment/CopyPaste/issues"); - await Windows.System.Launcher.LaunchUriAsync(uri); - } } diff --git a/CopyPaste.UI/Themes/Default/DefaultThemeWindow.xaml.cs b/CopyPaste.UI/Themes/Default/DefaultThemeWindow.xaml.cs index 8785fb7..cfc8439 100644 --- a/CopyPaste.UI/Themes/Default/DefaultThemeWindow.xaml.cs +++ b/CopyPaste.UI/Themes/Default/DefaultThemeWindow.xaml.cs @@ -10,11 +10,13 @@ using Microsoft.UI.Xaml.Shapes; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Windows.UI; using WinRT.Interop; namespace CopyPaste.UI.Themes; +[ExcludeFromCodeCoverage(Justification = "WinUI3 Window with event-driven UI — not unit testable")] internal sealed partial class DefaultThemeWindow : Window { public DefaultThemeViewModel ViewModel { get; } @@ -191,7 +193,7 @@ private void Window_Closed(object? _, WindowEventArgs args) SearchBox.KeyDown -= SearchBox_KeyDown; // Unsubscribe from ScrollViewer - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); if (scrollViewer != null) scrollViewer.ViewChanged -= ScrollViewer_ViewChanged; @@ -213,7 +215,7 @@ private void Window_Closed(object? _, WindowEventArgs args) private void ClipboardListView_Loaded(object? _, RoutedEventArgs __) { - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); if (scrollViewer != null) scrollViewer.ViewChanged += ScrollViewer_ViewChanged; } @@ -266,12 +268,7 @@ private void ConfigureSidebarStyle() MoveToRightEdge(); } - private void SetWindowIcon() - { - var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "Assets", "CopyPasteLogoSimple.ico"); - if (System.IO.File.Exists(iconPath)) - _appWindow.SetIcon(iconPath); - } + private void SetWindowIcon() => ClipboardWindowHelpers.SetWindowIcon(_appWindow); private void MoveToRightEdge() { @@ -289,32 +286,17 @@ private void AppWindow_Changed(AppWindow sender, AppWindowChangedEventArgs args) ViewModel.OnWindowDeactivated(); } - private static ScrollViewer? FindScrollViewer(DependencyObject parent) - { - for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) - { - var child = VisualTreeHelper.GetChild(parent, i); - if (child is ScrollViewer sv) - return sv; - - var result = FindScrollViewer(child); - if (result != null) - return result; - } - return null; - } - private static void Container_PointerEntered(object sender, Microsoft.UI.Xaml.Input.PointerRoutedEventArgs e) { if (sender is ListViewItem item && item.ContentTemplateRoot is FrameworkElement root) { - if (FindDescendant(root, "ActionPanel") is UIElement panel) + if (ClipboardWindowHelpers.FindDescendant(root, "ActionPanel") is UIElement panel) panel.Opacity = 1; - if (FindDescendant(root, "LabelTimestamp") is UIElement timestamp) + if (ClipboardWindowHelpers.FindDescendant(root, "LabelTimestamp") is UIElement timestamp) timestamp.Opacity = 0; - if (FindDescendant(root, "ImageBorder") is UIElement imageBorder) + if (ClipboardWindowHelpers.FindDescendant(root, "ImageBorder") is UIElement imageBorder) imageBorder.Opacity = 1; - if (FindDescendant(root, "MediaBorder") is UIElement mediaBorder) + if (ClipboardWindowHelpers.FindDescendant(root, "MediaBorder") is UIElement mediaBorder) mediaBorder.Opacity = 1; } } @@ -323,32 +305,17 @@ private static void Container_PointerExited(object sender, Microsoft.UI.Xaml.Inp { if (sender is ListViewItem item && item.ContentTemplateRoot is FrameworkElement root) { - if (FindDescendant(root, "ActionPanel") is UIElement panel) + if (ClipboardWindowHelpers.FindDescendant(root, "ActionPanel") is UIElement panel) panel.Opacity = 0; - if (FindDescendant(root, "LabelTimestamp") is UIElement timestamp) + if (ClipboardWindowHelpers.FindDescendant(root, "LabelTimestamp") is UIElement timestamp) timestamp.Opacity = 0.5; - if (FindDescendant(root, "ImageBorder") is UIElement imageBorder) + if (ClipboardWindowHelpers.FindDescendant(root, "ImageBorder") is UIElement imageBorder) imageBorder.Opacity = 0.6; - if (FindDescendant(root, "MediaBorder") is UIElement mediaBorder) + if (ClipboardWindowHelpers.FindDescendant(root, "MediaBorder") is UIElement mediaBorder) mediaBorder.Opacity = 0.6; } } - private static DependencyObject? FindDescendant(DependencyObject parent, string name) - { - if (parent is FrameworkElement fe && fe.Name == name) - return parent; - - for (int i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++) - { - var child = VisualTreeHelper.GetChild(parent, i); - var result = FindDescendant(child, name); - if (result != null) - return result; - } - return null; - } - private void ClipboardListView_ContainerContentChanging(ListViewBase _, ContainerContentChangingEventArgs args) { if (args.InRecycleQueue) @@ -426,47 +393,8 @@ private void LoadClipboardImage(ListViewBase sender, ContainerContentChangingEve } } - private void LoadImageSource(Image image, string? imagePath) - { - if (string.IsNullOrEmpty(imagePath)) return; - - if (!imagePath.StartsWith("ms-appx://", StringComparison.OrdinalIgnoreCase) && - !System.IO.File.Exists(imagePath)) - { - // Check if it's a thumbnail file (could be _t.png, _t.jpg, _t.webp, etc.) - if (imagePath.Contains("_t.", StringComparison.Ordinal)) - { - try - { - image.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage - { - UriSource = new Uri("ms-appx:///Assets/thumb/image.png") - }; - } - catch { /* Silently fail */ } - } - return; - } - - // Skip if already showing the same image (avoid creating duplicate BitmapImages) - if (image.Source is Microsoft.UI.Xaml.Media.Imaging.BitmapImage currentBitmap) - { - var currentPath = currentBitmap.UriSource?.LocalPath; - if (currentPath != null && imagePath.EndsWith(System.IO.Path.GetFileName(currentPath), StringComparison.OrdinalIgnoreCase)) - return; - } - - try - { - image.Source = new Microsoft.UI.Xaml.Media.Imaging.BitmapImage - { - UriSource = new Uri(imagePath), - CreateOptions = Microsoft.UI.Xaml.Media.Imaging.BitmapCreateOptions.None, - DecodePixelHeight = _config.ThumbnailUIDecodeHeight - }; - } - catch { /* Silently fail */ } - } + private void LoadImageSource(Image image, string? imagePath) => + ClipboardWindowHelpers.LoadImageSource(image, imagePath, _config.ThumbnailUIDecodeHeight); private void SearchBox_TextChanged(object? sender, TextChangedEventArgs _) { @@ -521,140 +449,14 @@ private void TypeCheckBox_Changed(object sender, RoutedEventArgs e) } } - private void SyncFilterChipsState() - { - // Sync color checkboxes - ColorCheckRed.IsChecked = ViewModel.IsColorSelected(CardColor.Red); - ColorCheckGreen.IsChecked = ViewModel.IsColorSelected(CardColor.Green); - ColorCheckPurple.IsChecked = ViewModel.IsColorSelected(CardColor.Purple); - ColorCheckYellow.IsChecked = ViewModel.IsColorSelected(CardColor.Yellow); - ColorCheckBlue.IsChecked = ViewModel.IsColorSelected(CardColor.Blue); - ColorCheckOrange.IsChecked = ViewModel.IsColorSelected(CardColor.Orange); - - // Sync type checkboxes - TypeCheckText.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Text); - TypeCheckImage.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Image); - TypeCheckFile.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.File); - TypeCheckFolder.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Folder); - TypeCheckLink.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Link); - TypeCheckAudio.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Audio); - TypeCheckVideo.IsChecked = ViewModel.IsTypeSelected(ClipboardContentType.Video); - - // Sync filter mode menu - FilterModeContent.IsChecked = ViewModel.ActiveFilterMode == 0; - FilterModeCategory.IsChecked = ViewModel.ActiveFilterMode == 1; - FilterModeType.IsChecked = ViewModel.ActiveFilterMode == 2; - - // Update visual displays - UpdateSelectedColorsDisplay(); - UpdateSelectedTypesDisplay(); - } - - private void UpdateSelectedColorsDisplay() - { - // Clear existing chips (except placeholder) - while (SelectedColorsPanel.Children.Count > 1) - SelectedColorsPanel.Children.RemoveAt(1); - - var selectedColors = new List<(CardColor color, string hex)>(); - if (ViewModel.IsColorSelected(CardColor.Red)) selectedColors.Add((CardColor.Red, "#E74C3C")); - if (ViewModel.IsColorSelected(CardColor.Green)) selectedColors.Add((CardColor.Green, "#2ECC71")); - if (ViewModel.IsColorSelected(CardColor.Purple)) selectedColors.Add((CardColor.Purple, "#9B59B6")); - if (ViewModel.IsColorSelected(CardColor.Yellow)) selectedColors.Add((CardColor.Yellow, "#F1C40F")); - if (ViewModel.IsColorSelected(CardColor.Blue)) selectedColors.Add((CardColor.Blue, "#3498DB")); - if (ViewModel.IsColorSelected(CardColor.Orange)) selectedColors.Add((CardColor.Orange, "#E67E22")); - - if (selectedColors.Count == 0) - { - ColorPlaceholder.Visibility = Visibility.Visible; - } - else - { - ColorPlaceholder.Visibility = Visibility.Collapsed; - foreach (var (_, hex) in selectedColors) - { - // Circular color indicator with better visibility - var chip = new Ellipse - { - Width = 16, - Height = 16, - Fill = new Microsoft.UI.Xaml.Media.SolidColorBrush(ParseColor(hex)), - Stroke = new Microsoft.UI.Xaml.Media.SolidColorBrush(Windows.UI.Color.FromArgb(40, 0, 0, 0)), - StrokeThickness = 1, - Margin = new Thickness(0, 0, 2, 0) - }; - SelectedColorsPanel.Children.Add(chip); - } - } - } - - private void UpdateSelectedTypesDisplay() - { - // Clear existing chips (except placeholder) - while (SelectedTypesPanel.Children.Count > 1) - SelectedTypesPanel.Children.RemoveAt(1); - - var selectedTypes = new List<(ClipboardContentType type, string glyph)>(); - if (ViewModel.IsTypeSelected(ClipboardContentType.Text)) selectedTypes.Add((ClipboardContentType.Text, "\uE8C1")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Image)) selectedTypes.Add((ClipboardContentType.Image, "\uE91B")); - if (ViewModel.IsTypeSelected(ClipboardContentType.File)) selectedTypes.Add((ClipboardContentType.File, "\uE7C3")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Folder)) selectedTypes.Add((ClipboardContentType.Folder, "\uE8B7")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Link)) selectedTypes.Add((ClipboardContentType.Link, "\uE71B")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Audio)) selectedTypes.Add((ClipboardContentType.Audio, "\uE8D6")); - if (ViewModel.IsTypeSelected(ClipboardContentType.Video)) selectedTypes.Add((ClipboardContentType.Video, "\uE714")); + private void SyncFilterChipsState() => + ClipboardFilterChipsHelper.SyncFilterChipsState(ViewModel, (FrameworkElement)Content); - if (selectedTypes.Count == 0) - { - TypePlaceholder.Visibility = Visibility.Visible; - } - else - { - TypePlaceholder.Visibility = Visibility.Collapsed; - var maxToShow = 5; - var shown = 0; - foreach (var (_, glyph) in selectedTypes) - { - if (shown >= maxToShow) - { - var moreText = new TextBlock - { - Text = $"+{selectedTypes.Count - maxToShow}", - FontSize = 10, - Opacity = 0.6, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(4, 0, 0, 0) - }; - SelectedTypesPanel.Children.Add(moreText); - break; - } - // Icon chip with background for better distinction - var chipBorder = new Border - { - Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Windows.UI.Color.FromArgb(30, 128, 128, 128)), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 3, 6, 3), - Child = new FontIcon - { - Glyph = glyph, - FontSize = 12 - } - }; - SelectedTypesPanel.Children.Add(chipBorder); - shown++; - } - } - } + private void UpdateSelectedColorsDisplay() => + ClipboardFilterChipsHelper.UpdateSelectedColorsDisplay(ViewModel, (FrameworkElement)Content); - private static Windows.UI.Color ParseColor(string hex) - { - hex = hex.TrimStart('#'); - return Windows.UI.Color.FromArgb( - 255, - byte.Parse(hex.AsSpan(0, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture), - byte.Parse(hex.AsSpan(2, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture), - byte.Parse(hex.AsSpan(4, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture) - ); - } + private void UpdateSelectedTypesDisplay() => + ClipboardFilterChipsHelper.UpdateSelectedTypesDisplay(ViewModel, (FrameworkElement)Content); private void MainContent_PreviewKeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e) { @@ -852,9 +654,9 @@ private void ClipboardListView_SelectionChanged(object sender, Microsoft.UI.Xaml var removedContainer = ClipboardListView.ContainerFromItem(removed) as ListViewItem; if (removedContainer?.ContentTemplateRoot is FrameworkElement removedRoot) { - if (FindDescendant(removedRoot, "ImageBorder") is UIElement imageBorder) + if (ClipboardWindowHelpers.FindDescendant(removedRoot, "ImageBorder") is UIElement imageBorder) imageBorder.Opacity = 0.6; - if (FindDescendant(removedRoot, "MediaBorder") is UIElement mediaBorder) + if (ClipboardWindowHelpers.FindDescendant(removedRoot, "MediaBorder") is UIElement mediaBorder) mediaBorder.Opacity = 0.6; } } @@ -868,9 +670,9 @@ private void ClipboardListView_SelectionChanged(object sender, Microsoft.UI.Xaml var addedContainer = ClipboardListView.ContainerFromItem(added) as ListViewItem; if (addedContainer?.ContentTemplateRoot is FrameworkElement addedRoot) { - if (FindDescendant(addedRoot, "ImageBorder") is UIElement imageBorder) + if (ClipboardWindowHelpers.FindDescendant(addedRoot, "ImageBorder") is UIElement imageBorder) imageBorder.Opacity = 1; - if (FindDescendant(addedRoot, "MediaBorder") is UIElement mediaBorder) + if (ClipboardWindowHelpers.FindDescendant(addedRoot, "MediaBorder") is UIElement mediaBorder) mediaBorder.Opacity = 1; } } @@ -898,7 +700,7 @@ private void FocusSearchBox() => private void ResetScrollToTop() { - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); scrollViewer?.ChangeView(null, 0, null, disableAnimation: true); if (ClipboardListView.Items.Count > 0) @@ -911,7 +713,7 @@ private void ViewModel_OnScrollToTopRequested(object? sender, EventArgs e) DispatcherQueue.TryEnqueue(() => { - var scrollViewer = FindScrollViewer(ClipboardListView); + var scrollViewer = ClipboardWindowHelpers.FindScrollViewer(ClipboardListView); scrollViewer?.ChangeView(null, 0, null, disableAnimation: false); if (ClipboardListView.Items.Count > 0) @@ -931,70 +733,12 @@ private void TrayMenuSettings_Click(object sender, RoutedEventArgs e) => private async void ShowEditDialog(object? sender, ClipboardItemViewModel itemVM) { _isDialogOpen = true; - try { - var labelBox = new TextBox - { - Text = itemVM.Label ?? string.Empty, - PlaceholderText = L.Get("clipboard.editDialog.labelPlaceholder"), - MaxLength = ClipboardItem.MaxLabelLength, - HorizontalAlignment = HorizontalAlignment.Stretch - }; - - var colorPanel = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 12, 0, 0) }; - var colorLabel = new TextBlock - { - Text = L.Get("clipboard.editDialog.colorLabel"), - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 8, 0), - Opacity = 0.7 - }; - colorPanel.Children.Add(colorLabel); - - var colorCombo = new ComboBox { Width = 140 }; - colorCombo.Items.Add(new ComboBoxItem { Content = L.Get("clipboard.editDialog.colorNone"), Tag = CardColor.None }); - colorCombo.Items.Add(new ComboBoxItem { Content = GetColorLabelWithFallback("Red", "clipboard.editDialog.colorRed"), Tag = CardColor.Red }); - colorCombo.Items.Add(new ComboBoxItem { Content = GetColorLabelWithFallback("Green", "clipboard.editDialog.colorGreen"), Tag = CardColor.Green }); - colorCombo.Items.Add(new ComboBoxItem { Content = GetColorLabelWithFallback("Purple", "clipboard.editDialog.colorPurple"), Tag = CardColor.Purple }); - colorCombo.Items.Add(new ComboBoxItem { Content = GetColorLabelWithFallback("Yellow", "clipboard.editDialog.colorYellow"), Tag = CardColor.Yellow }); - colorCombo.Items.Add(new ComboBoxItem { Content = GetColorLabelWithFallback("Blue", "clipboard.editDialog.colorBlue"), Tag = CardColor.Blue }); - colorCombo.Items.Add(new ComboBoxItem { Content = GetColorLabelWithFallback("Orange", "clipboard.editDialog.colorOrange"), Tag = CardColor.Orange }); - - colorCombo.SelectedIndex = (int)itemVM.CardColor; - colorPanel.Children.Add(colorCombo); - - var hintText = new TextBlock - { - Text = L.Get("clipboard.editDialog.labelHint"), - FontSize = 11, - Opacity = 0.5, - Margin = new Thickness(0, 4, 0, 0) - }; - - var contentPanel = new StackPanel { Spacing = 4 }; - contentPanel.Children.Add(labelBox); - contentPanel.Children.Add(hintText); - contentPanel.Children.Add(colorPanel); - - var dialog = new ContentDialog - { - Title = L.Get("clipboard.editDialog.title"), - Content = contentPanel, - PrimaryButtonText = L.Get("clipboard.editDialog.save"), - CloseButtonText = L.Get("clipboard.editDialog.cancel"), - DefaultButton = ContentDialogButton.Primary, - XamlRoot = Content.XamlRoot - }; - - var result = await dialog.ShowAsync(); - - if (result == ContentDialogResult.Primary && colorCombo.SelectedItem is ComboBoxItem selectedColor) - { - var label = string.IsNullOrWhiteSpace(labelBox.Text) ? null : labelBox.Text.Trim(); - var color = (CardColor)(selectedColor.Tag ?? CardColor.None); - ViewModel.SaveItemLabelAndColor(itemVM, label, color); - } + var result = await ClipboardWindowHelpers.ShowEditDialogAsync( + Content.XamlRoot, itemVM, GetColorLabelWithFallback); + if (result is { } r) + ViewModel.SaveItemLabelAndColor(itemVM, r.label, r.color); } finally { diff --git a/CopyPaste.UI/Themes/ThemeRegistry.cs b/CopyPaste.UI/Themes/ThemeRegistry.cs index c83b5bd..645ecb7 100644 --- a/CopyPaste.UI/Themes/ThemeRegistry.cs +++ b/CopyPaste.UI/Themes/ThemeRegistry.cs @@ -2,12 +2,14 @@ using CopyPaste.Core.Themes; using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; namespace CopyPaste.UI.Themes; +[ExcludeFromCodeCoverage(Justification = "Creates ITheme instances (WinUI3 Windows) via reflection — requires WinUI3 runtime")] internal sealed class ThemeRegistry { private readonly Dictionary> _factories = new(StringComparer.OrdinalIgnoreCase); diff --git a/CopyPaste.slnx b/CopyPaste.slnx index 0a831fb..4740bf7 100644 --- a/CopyPaste.slnx +++ b/CopyPaste.slnx @@ -27,8 +27,17 @@ - - - + + + + + + + + + + + + diff --git a/Tests/CopyPaste.Core.Tests/AppLoggerTests.cs b/Tests/CopyPaste.Core.Tests/AppLoggerTests.cs new file mode 100644 index 0000000..1a5c173 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/AppLoggerTests.cs @@ -0,0 +1,176 @@ +using Xunit; + +namespace CopyPaste.Core.Tests; + +public class AppLoggerTests +{ + #region IsEnabled Tests + + [Fact] + public void IsEnabled_DefaultIsTrue() + { + Assert.True(AppLogger.IsEnabled); + } + + [Fact] + public void IsEnabled_CanBeDisabledAndReEnabled() + { + var original = AppLogger.IsEnabled; + try + { + AppLogger.IsEnabled = false; + Assert.False(AppLogger.IsEnabled); + + AppLogger.IsEnabled = true; + Assert.True(AppLogger.IsEnabled); + } + finally + { + AppLogger.IsEnabled = original; + } + } + + #endregion + + #region LogFilePath and LogDirectory Tests + + [Fact] + public void LogFilePath_IsNotNullOrEmpty() + { + Assert.NotNull(AppLogger.LogFilePath); + Assert.NotEmpty(AppLogger.LogFilePath); + } + + [Fact] + public void LogDirectory_IsNotNullOrEmpty() + { + Assert.NotNull(AppLogger.LogDirectory); + Assert.NotEmpty(AppLogger.LogDirectory); + } + + [Fact] + public void LogFilePath_ContainsLogDirectory() + { + Assert.Contains(AppLogger.LogDirectory, AppLogger.LogFilePath, System.StringComparison.Ordinal); + } + + [Fact] + public void LogFilePath_ContainsCopyPaste() + { + Assert.Contains("copypaste_", AppLogger.LogFilePath, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LogFilePath_HasLogExtension() + { + Assert.EndsWith(".log", AppLogger.LogFilePath, System.StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LogDirectory_ContainsCopyPaste() + { + Assert.Contains("CopyPaste", AppLogger.LogDirectory, System.StringComparison.OrdinalIgnoreCase); + } + + #endregion + + #region Log Methods Don't Throw When Disabled + + [Fact] + public void Info_WhenDisabled_DoesNotThrow() + { + var original = AppLogger.IsEnabled; + AppLogger.IsEnabled = false; + var ex = Record.Exception(() => AppLogger.Info("test message")); + AppLogger.IsEnabled = original; + Assert.Null(ex); + } + + [Fact] + public void Warn_WhenDisabled_DoesNotThrow() + { + var original = AppLogger.IsEnabled; + AppLogger.IsEnabled = false; + var ex = Record.Exception(() => AppLogger.Warn("test warning")); + AppLogger.IsEnabled = original; + Assert.Null(ex); + } + + [Fact] + public void Error_WhenDisabled_DoesNotThrow() + { + var original = AppLogger.IsEnabled; + AppLogger.IsEnabled = false; + var ex = Record.Exception(() => AppLogger.Error("test error")); + AppLogger.IsEnabled = original; + Assert.Null(ex); + } + + [Fact] + public void Exception_WhenDisabled_DoesNotThrow() + { + var original = AppLogger.IsEnabled; + AppLogger.IsEnabled = false; + var ex = Record.Exception(() => AppLogger.Exception(new System.InvalidOperationException("test"), "context")); + AppLogger.IsEnabled = original; + Assert.Null(ex); + } + + [Fact] + public void Exception_WithNullException_DoesNotThrow() + { + var ex = Record.Exception(() => AppLogger.Exception(null, "context")); + Assert.Null(ex); + } + + [Fact] + public void Exception_WithInnerException_DoesNotThrow() + { + var inner = new System.ArgumentException("inner error"); + var outer = new System.InvalidOperationException("outer error", inner); + var ex = Record.Exception(() => AppLogger.Exception(outer, "test context")); + Assert.Null(ex); + } + + [Fact] + public void Exception_WithEmptyContext_DoesNotThrow() + { + var ex = Record.Exception(() => AppLogger.Exception(new System.InvalidOperationException("test"), "")); + Assert.Null(ex); + } + + [Fact] + public void Exception_WithNullContext_DoesNotThrow() + { + var ex = Record.Exception(() => AppLogger.Exception(new System.InvalidOperationException("test"))); + Assert.Null(ex); + } + + #endregion + + #region Initialize Tests + + [Fact] + public void Initialize_CanBeCalledMultipleTimes() + { + AppLogger.Initialize(); + AppLogger.Initialize(); + AppLogger.Initialize(); + Assert.True(AppLogger.IsEnabled); + } + + [Fact] + public void Initialize_AfterInit_LoggingWorks() + { + AppLogger.Initialize(); + var ex = Record.Exception(() => + { + AppLogger.Info("test after init"); + AppLogger.Warn("warn after init"); + AppLogger.Error("error after init"); + }); + Assert.Null(ex); + } + + #endregion +} diff --git a/Tests/CopyPaste.Core.Tests/BackupManifestTests.cs b/Tests/CopyPaste.Core.Tests/BackupManifestTests.cs new file mode 100644 index 0000000..47f17d9 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/BackupManifestTests.cs @@ -0,0 +1,142 @@ +using System; +using System.Text.Json; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public class BackupManifestTests +{ + #region Default Values + + [Fact] + public void DefaultManifest_Version_Is1() + { + var manifest = new BackupManifest(); + Assert.Equal(1, manifest.Version); + } + + [Fact] + public void DefaultManifest_AppVersion_IsEmpty() + { + var manifest = new BackupManifest(); + Assert.Equal(string.Empty, manifest.AppVersion); + } + + [Fact] + public void DefaultManifest_CreatedAtUtc_IsDefault() + { + var manifest = new BackupManifest(); + Assert.Equal(default, manifest.CreatedAtUtc); + } + + [Fact] + public void DefaultManifest_ItemCount_IsZero() + { + var manifest = new BackupManifest(); + Assert.Equal(0, manifest.ItemCount); + } + + [Fact] + public void DefaultManifest_ImageCount_IsZero() + { + var manifest = new BackupManifest(); + Assert.Equal(0, manifest.ImageCount); + } + + [Fact] + public void DefaultManifest_ThumbnailCount_IsZero() + { + var manifest = new BackupManifest(); + Assert.Equal(0, manifest.ThumbnailCount); + } + + [Fact] + public void DefaultManifest_HasPinnedItems_IsFalse() + { + var manifest = new BackupManifest(); + Assert.False(manifest.HasPinnedItems); + } + + [Fact] + public void DefaultManifest_MachineName_IsEmpty() + { + var manifest = new BackupManifest(); + Assert.Equal(string.Empty, manifest.MachineName); + } + + #endregion + + #region Property Setters + + [Fact] + public void AllProperties_CanBeSet() + { + var now = DateTime.UtcNow; + var manifest = new BackupManifest + { + Version = 2, + AppVersion = "1.5.0", + CreatedAtUtc = now, + ItemCount = 100, + ImageCount = 25, + ThumbnailCount = 25, + HasPinnedItems = true, + MachineName = "MY-PC" + }; + + Assert.Equal(2, manifest.Version); + Assert.Equal("1.5.0", manifest.AppVersion); + Assert.Equal(now, manifest.CreatedAtUtc); + Assert.Equal(100, manifest.ItemCount); + Assert.Equal(25, manifest.ImageCount); + Assert.Equal(25, manifest.ThumbnailCount); + Assert.True(manifest.HasPinnedItems); + Assert.Equal("MY-PC", manifest.MachineName); + } + + #endregion + + #region JSON Serialization + + [Fact] + public void JsonSerialization_RoundTrip_PreservesValues() + { + var now = DateTime.UtcNow; + var original = new BackupManifest + { + Version = 1, + AppVersion = "2.0.0", + CreatedAtUtc = now, + ItemCount = 50, + ImageCount = 10, + ThumbnailCount = 10, + HasPinnedItems = true, + MachineName = "TEST-PC" + }; + + var json = JsonSerializer.Serialize(original, BackupManifestJsonContext.Default.BackupManifest); + var deserialized = JsonSerializer.Deserialize(json, BackupManifestJsonContext.Default.BackupManifest); + + Assert.NotNull(deserialized); + Assert.Equal(original.Version, deserialized.Version); + Assert.Equal(original.AppVersion, deserialized.AppVersion); + Assert.Equal(original.ItemCount, deserialized.ItemCount); + Assert.Equal(original.ImageCount, deserialized.ImageCount); + Assert.Equal(original.ThumbnailCount, deserialized.ThumbnailCount); + Assert.Equal(original.HasPinnedItems, deserialized.HasPinnedItems); + Assert.Equal(original.MachineName, deserialized.MachineName); + } + + [Fact] + public void JsonDeserialization_EmptyJson_UsesDefaults() + { + var json = "{}"; + var manifest = JsonSerializer.Deserialize(json, BackupManifestJsonContext.Default.BackupManifest); + + Assert.NotNull(manifest); + Assert.Equal(1, manifest.Version); + Assert.Equal(0, manifest.ItemCount); + } + + #endregion +} diff --git a/Tests/CopyPaste.Core.Tests/BackupServiceTests.cs b/Tests/CopyPaste.Core.Tests/BackupServiceTests.cs index 2408f58..b7108ab 100644 --- a/Tests/CopyPaste.Core.Tests/BackupServiceTests.cs +++ b/Tests/CopyPaste.Core.Tests/BackupServiceTests.cs @@ -1,8 +1,6 @@ using System; using System.IO; using System.IO.Compression; -using System.Text; -using System.Text.Json; using Microsoft.Data.Sqlite; using Xunit; @@ -19,444 +17,266 @@ public BackupServiceTests() StorageConfig.Initialize(); } - #region CreateBackup Tests - - [Fact] - public void CreateBackup_WritesValidZip() + public void Dispose() { - SeedDatabase(); - - using var output = new MemoryStream(); - BackupService.CreateBackup(output, "1.0.0"); - - output.Position = 0; - using var archive = new ZipArchive(output, ZipArchiveMode.Read); - Assert.NotNull(archive.GetEntry("manifest.json")); - Assert.NotNull(archive.GetEntry("clipboard.db")); + SqliteConnection.ClearAllPools(); + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } } - [Fact] - public void CreateBackup_ManifestContainsCorrectItemCount() + private static void CreateMinimalDatabase() { - SeedDatabaseWithItems(5); - - using var output = new MemoryStream(); - var manifest = BackupService.CreateBackup(output, "2.0.0"); - - Assert.Equal(5, manifest.ItemCount); + using var connection = new SqliteConnection($"Data Source={StorageConfig.DatabasePath}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS ClipboardItems ( + Id TEXT PRIMARY KEY, Content TEXT NOT NULL, Type INTEGER NOT NULL, + CreatedAt TEXT NOT NULL, ModifiedAt TEXT NOT NULL, AppSource TEXT, + IsPinned INTEGER NOT NULL DEFAULT 0, Metadata TEXT, Label TEXT, + CardColor INTEGER NOT NULL DEFAULT 0, PasteCount INTEGER NOT NULL DEFAULT 0, + ContentHash TEXT)"; + cmd.ExecuteNonQuery(); } - [Fact] - public void CreateBackup_ManifestHasCorrectVersion() - { - using var output = new MemoryStream(); - var manifest = BackupService.CreateBackup(output, "1.5.0"); - - Assert.Equal(BackupService.CurrentVersion, manifest.Version); - Assert.Equal("1.5.0", manifest.AppVersion); - } + #region CurrentVersion [Fact] - public void CreateBackup_IncludesImages() + public void CurrentVersion_Is1() { - // Create a fake image file - var imagePath = Path.Combine(StorageConfig.ImagesPath, "test-image.png"); - File.WriteAllBytes(imagePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + Assert.Equal(1, BackupService.CurrentVersion); + } - using var output = new MemoryStream(); - var manifest = BackupService.CreateBackup(output, "1.0.0"); + #endregion - Assert.Equal(1, manifest.ImageCount); + #region CreateBackup - output.Position = 0; - using var archive = new ZipArchive(output, ZipArchiveMode.Read); - Assert.NotNull(archive.GetEntry("images/test-image.png")); + [Fact] + public void CreateBackup_WithNullStream_ThrowsArgumentNullException() + { + Assert.Throws(() => BackupService.CreateBackup(null!, "1.0.0")); } [Fact] - public void CreateBackup_IncludesThumbnails() + public void CreateBackup_EmptyDatabase_ReturnsManifest() { - var thumbPath = Path.Combine(StorageConfig.ThumbnailsPath, "thumb_t.png"); - File.WriteAllBytes(thumbPath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + CreateMinimalDatabase(); using var output = new MemoryStream(); var manifest = BackupService.CreateBackup(output, "1.0.0"); - Assert.Equal(1, manifest.ThumbnailCount); + Assert.NotNull(manifest); + Assert.Equal(0, manifest.ItemCount); } [Fact] - public void CreateBackup_IncludesConfig() + public void CreateBackup_WritesValidZipToStream() { - var configPath = Path.Combine(StorageConfig.ConfigPath, "MyM.json"); - File.WriteAllText(configPath, """{"PreferredLanguage":"es-CL"}"""); + CreateMinimalDatabase(); using var output = new MemoryStream(); BackupService.CreateBackup(output, "1.0.0"); output.Position = 0; using var archive = new ZipArchive(output, ZipArchiveMode.Read); - Assert.NotNull(archive.GetEntry("config/MyM.json")); + Assert.NotNull(archive.GetEntry("manifest.json")); } [Fact] - public void CreateBackup_ManifestHasMachineName() + public void CreateBackup_ManifestHasCorrectVersion() { using var output = new MemoryStream(); var manifest = BackupService.CreateBackup(output, "1.0.0"); - Assert.Equal(Environment.MachineName, manifest.MachineName); + Assert.Equal(BackupService.CurrentVersion, manifest.Version); } [Fact] - public void CreateBackup_ManifestHasTimestamp() + public void CreateBackup_ManifestHasAppVersion() { - var before = DateTime.UtcNow.AddSeconds(-1); - using var output = new MemoryStream(); - var manifest = BackupService.CreateBackup(output, "1.0.0"); + var manifest = BackupService.CreateBackup(output, "2.5.0"); - var after = DateTime.UtcNow.AddSeconds(1); - Assert.InRange(manifest.CreatedAtUtc, before, after); + Assert.Equal("2.5.0", manifest.AppVersion); } [Fact] - public void CreateBackup_EmptyDatabase_Succeeds() + public void CreateBackup_ManifestHasMachineName() { using var output = new MemoryStream(); var manifest = BackupService.CreateBackup(output, "1.0.0"); - Assert.Equal(0, manifest.ItemCount); - Assert.False(manifest.HasPinnedItems); + Assert.Equal(Environment.MachineName, manifest.MachineName); } [Fact] - public void CreateBackup_DetectsPinnedItems() + public void CreateBackup_ManifestHasCreatedAtUtc() { - SeedDatabaseWithPinnedItem(); + var before = DateTime.UtcNow.AddSeconds(-1); using var output = new MemoryStream(); var manifest = BackupService.CreateBackup(output, "1.0.0"); - Assert.True(manifest.HasPinnedItems); - } - - [Fact] - public void CreateBackup_ThrowsOnNullStream() - { - Assert.Throws(() => BackupService.CreateBackup(null!, "1.0.0")); + var after = DateTime.UtcNow.AddSeconds(1); + Assert.InRange(manifest.CreatedAtUtc, before, after); } #endregion - #region ValidateBackup Tests + #region ValidateBackup and GetBackupInfo [Fact] - public void ValidateBackup_ValidZip_ReturnsManifest() + public void ValidateBackup_WithNullStream_ThrowsArgumentNullException() { - using var backupStream = CreateTestBackup(); - backupStream.Position = 0; - - var manifest = BackupService.ValidateBackup(backupStream); - - Assert.NotNull(manifest); - Assert.Equal(BackupService.CurrentVersion, manifest.Version); + Assert.Throws(() => BackupService.ValidateBackup(null!)); } [Fact] - public void ValidateBackup_InvalidZip_ReturnsNull() + public void ValidateBackup_WithValidBackup_ReturnsManifest() { - using var invalidStream = new MemoryStream(Encoding.UTF8.GetBytes("not a zip file")); + using var stream = new MemoryStream(); + BackupService.CreateBackup(stream, "1.0.0"); + stream.Position = 0; - var manifest = BackupService.ValidateBackup(invalidStream); + var manifest = BackupService.ValidateBackup(stream); - Assert.Null(manifest); + Assert.NotNull(manifest); + Assert.Equal(BackupService.CurrentVersion, manifest.Version); } [Fact] - public void ValidateBackup_ZipWithoutManifest_ReturnsNull() + public void ValidateBackup_WithInvalidStream_ReturnsNull() { - using var zipStream = new MemoryStream(); - using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create, leaveOpen: true)) - { - var entry = archive.CreateEntry("random.txt"); - using var writer = new StreamWriter(entry.Open()); - writer.Write("hello"); - } + using var stream = new MemoryStream(); - zipStream.Position = 0; - var manifest = BackupService.ValidateBackup(zipStream); + var manifest = BackupService.ValidateBackup(stream); Assert.Null(manifest); } [Fact] - public void ValidateBackup_ThrowsOnNullStream() + public void GetBackupInfo_WithNullStream_ThrowsArgumentNullException() { - Assert.Throws(() => BackupService.ValidateBackup(null!)); + Assert.Throws(() => BackupService.GetBackupInfo(null!)); } - #endregion - - #region GetBackupInfo Tests - [Fact] - public void GetBackupInfo_ReturnsManifestData() + public void GetBackupInfo_WithValidBackup_ReturnsManifest() { - using var backupStream = CreateTestBackup(itemCount: 10); - backupStream.Position = 0; + using var stream = new MemoryStream(); + BackupService.CreateBackup(stream, "1.0.0"); + stream.Position = 0; - var info = BackupService.GetBackupInfo(backupStream); + var info = BackupService.GetBackupInfo(stream); Assert.NotNull(info); - Assert.Equal("1.0.0", info.AppVersion); - } - - #endregion - - #region RestoreBackup Tests - - [Fact] - public void RestoreBackup_RestoresDatabase() - { - // Seed and backup - SeedDatabaseWithItems(3); - using var backupStream = new MemoryStream(); - BackupService.CreateBackup(backupStream, "1.0.0"); - - // Clear current data (release SQLite shared cache first) - SqliteConnection.ClearAllPools(); - File.Delete(StorageConfig.DatabasePath); - Assert.False(File.Exists(StorageConfig.DatabasePath)); - - // Restore - backupStream.Position = 0; - var manifest = BackupService.RestoreBackup(backupStream); - - Assert.NotNull(manifest); - Assert.True(File.Exists(StorageConfig.DatabasePath)); + Assert.Equal(BackupService.CurrentVersion, info.Version); } [Fact] - public void RestoreBackup_RestoresImages() + public void GetBackupInfo_SameAsValidateBackup() { - // Create image and backup - var imagePath = Path.Combine(StorageConfig.ImagesPath, "restored-image.png"); - File.WriteAllBytes(imagePath, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); + using var stream1 = new MemoryStream(); + BackupService.CreateBackup(stream1, "3.0.0"); - using var backupStream = new MemoryStream(); - BackupService.CreateBackup(backupStream, "1.0.0"); + using var stream2 = new MemoryStream(stream1.ToArray()); - // Delete image - File.Delete(imagePath); - Assert.False(File.Exists(imagePath)); + stream1.Position = 0; + var validateResult = BackupService.ValidateBackup(stream1); - // Restore - backupStream.Position = 0; - BackupService.RestoreBackup(backupStream); + stream2.Position = 0; + var infoResult = BackupService.GetBackupInfo(stream2); - Assert.True(File.Exists(imagePath)); + Assert.NotNull(validateResult); + Assert.NotNull(infoResult); + Assert.Equal(validateResult.Version, infoResult.Version); + Assert.Equal(validateResult.AppVersion, infoResult.AppVersion); + Assert.Equal(validateResult.MachineName, infoResult.MachineName); + Assert.Equal(validateResult.ItemCount, infoResult.ItemCount); } - [Fact] - public void RestoreBackup_RestoresConfig() - { - // Create config and backup - var configFile = Path.Combine(StorageConfig.ConfigPath, "MyM.json"); - File.WriteAllText(configFile, """{"RetentionDays":60}"""); - - using var backupStream = new MemoryStream(); - BackupService.CreateBackup(backupStream, "1.0.0"); - - // Delete config - File.Delete(configFile); - Assert.False(File.Exists(configFile)); + #endregion - // Restore - backupStream.Position = 0; - BackupService.RestoreBackup(backupStream); - - Assert.True(File.Exists(configFile)); - Assert.Contains("60", File.ReadAllText(configFile), StringComparison.Ordinal); - } + #region RestoreBackup [Fact] - public void RestoreBackup_CleansOrphanFilesInImageDirectory() + public void RestoreBackup_WithNullStream_ThrowsArgumentNullException() { - // Backup with one image - var imagePath = Path.Combine(StorageConfig.ImagesPath, "good.png"); - File.WriteAllBytes(imagePath, new byte[] { 0x01 }); - - using var backupStream = new MemoryStream(); - BackupService.CreateBackup(backupStream, "1.0.0"); - - // Add an orphan image that shouldn't exist after restore - var orphanPath = Path.Combine(StorageConfig.ImagesPath, "orphan.png"); - File.WriteAllBytes(orphanPath, new byte[] { 0x02 }); - - // Restore - backupStream.Position = 0; - BackupService.RestoreBackup(backupStream); - - Assert.True(File.Exists(imagePath)); - Assert.False(File.Exists(orphanPath)); + Assert.Throws(() => BackupService.RestoreBackup(null!)); } [Fact] - public void RestoreBackup_InvalidStream_ReturnsNull() + public void RestoreBackup_WithValidBackup_ReturnsManifest() { - using var invalidStream = new MemoryStream(Encoding.UTF8.GetBytes("not a zip")); + CreateMinimalDatabase(); - var result = BackupService.RestoreBackup(invalidStream); + using var stream = new MemoryStream(); + BackupService.CreateBackup(stream, "1.0.0"); - Assert.Null(result); - } + SqliteConnection.ClearAllPools(); + stream.Position = 0; + var manifest = BackupService.RestoreBackup(stream); - [Fact] - public void RestoreBackup_ThrowsOnNullStream() - { - Assert.Throws(() => BackupService.RestoreBackup(null!)); + Assert.NotNull(manifest); } - #endregion - - #region Roundtrip Tests - [Fact] - public void Roundtrip_BackupAndRestore_PreservesAllData() + public void RestoreBackup_WithInvalidStream_ReturnsNull() { - // Setup: DB + images + thumbs + config - SeedDatabaseWithItems(5); - - var img = Path.Combine(StorageConfig.ImagesPath, "roundtrip.png"); - File.WriteAllBytes(img, new byte[] { 0x89, 0x50 }); - - var thumb = Path.Combine(StorageConfig.ThumbnailsPath, "roundtrip_t.png"); - File.WriteAllBytes(thumb, new byte[] { 0xAA, 0xBB }); + using var stream = new MemoryStream(); - var config = Path.Combine(StorageConfig.ConfigPath, "MyM.json"); - File.WriteAllText(config, """{"PreferredLanguage":"ja-JP"}"""); + var manifest = BackupService.RestoreBackup(stream); - // Backup - using var backupStream = new MemoryStream(); - var backupManifest = BackupService.CreateBackup(backupStream, "3.0.0"); - - // Simulate data loss (release SQLite shared cache first) - SqliteConnection.ClearAllPools(); - File.Delete(StorageConfig.DatabasePath); - File.Delete(img); - File.Delete(thumb); - File.Delete(config); - - // Restore - backupStream.Position = 0; - var restoredManifest = BackupService.RestoreBackup(backupStream); - - // Verify - Assert.NotNull(restoredManifest); - Assert.Equal(backupManifest.ItemCount, restoredManifest.ItemCount); - Assert.True(File.Exists(StorageConfig.DatabasePath)); - Assert.True(File.Exists(img)); - Assert.True(File.Exists(thumb)); - Assert.True(File.Exists(config)); - Assert.Contains("ja-JP", File.ReadAllText(config), StringComparison.Ordinal); + Assert.Null(manifest); } #endregion - #region BackupManifest Serialization Tests + #region Round-trip [Fact] - public void BackupManifest_Serialization_Roundtrip() - { - var manifest = new BackupManifest - { - Version = 1, - AppVersion = "1.2.3", - CreatedAtUtc = new DateTime(2026, 2, 12, 10, 0, 0, DateTimeKind.Utc), - ItemCount = 42, - ImageCount = 10, - ThumbnailCount = 10, - HasPinnedItems = true, - MachineName = "TEST-PC" - }; - - var json = JsonSerializer.Serialize(manifest, BackupManifestJsonContext.Default.BackupManifest); - var deserialized = JsonSerializer.Deserialize(json, BackupManifestJsonContext.Default.BackupManifest); - - Assert.NotNull(deserialized); - Assert.Equal(manifest.Version, deserialized.Version); - Assert.Equal(manifest.AppVersion, deserialized.AppVersion); - Assert.Equal(manifest.ItemCount, deserialized.ItemCount); - Assert.Equal(manifest.ImageCount, deserialized.ImageCount); - Assert.Equal(manifest.ThumbnailCount, deserialized.ThumbnailCount); - Assert.True(deserialized.HasPinnedItems); - Assert.Equal("TEST-PC", deserialized.MachineName); - } - - #endregion - - #region Helpers - - private static void SeedDatabase() + public void CreateAndValidate_RoundTrip_PreservesManifestData() { - using var repo = new SqliteRepository(StorageConfig.DatabasePath); - repo.Save(new ClipboardItem - { - Content = "test content", - Type = ClipboardContentType.Text - }); + CreateMinimalDatabase(); + + using var stream = new MemoryStream(); + var created = BackupService.CreateBackup(stream, "5.0.0"); + stream.Position = 0; + + var validated = BackupService.ValidateBackup(stream); + + Assert.NotNull(validated); + Assert.Equal(created.Version, validated.Version); + Assert.Equal(created.AppVersion, validated.AppVersion); + Assert.Equal(created.MachineName, validated.MachineName); + Assert.Equal(created.ItemCount, validated.ItemCount); + Assert.Equal(created.ImageCount, validated.ImageCount); + Assert.Equal(created.ThumbnailCount, validated.ThumbnailCount); + Assert.Equal(created.HasPinnedItems, validated.HasPinnedItems); } - private static void SeedDatabaseWithItems(int count) + [Fact] + public void CreateAndRestore_RoundTrip_Works() { - using var repo = new SqliteRepository(StorageConfig.DatabasePath); - for (int i = 0; i < count; i++) - { - repo.Save(new ClipboardItem - { - Content = $"item {i}", - Type = ClipboardContentType.Text - }); - } - } + CreateMinimalDatabase(); - private static void SeedDatabaseWithPinnedItem() - { - using var repo = new SqliteRepository(StorageConfig.DatabasePath); - repo.Save(new ClipboardItem - { - Content = "pinned item", - Type = ClipboardContentType.Text, - IsPinned = true - }); - } + using var stream = new MemoryStream(); + var created = BackupService.CreateBackup(stream, "1.0.0"); - private static MemoryStream CreateTestBackup(int itemCount = 0) - { - if (itemCount > 0) - SeedDatabaseWithItems(itemCount); - else - SeedDatabase(); + SqliteConnection.ClearAllPools(); + if (File.Exists(StorageConfig.DatabasePath)) + File.Delete(StorageConfig.DatabasePath); - var stream = new MemoryStream(); - BackupService.CreateBackup(stream, "1.0.0"); - return stream; - } + stream.Position = 0; + var restored = BackupService.RestoreBackup(stream); - public void Dispose() - { - try - { - if (Directory.Exists(_basePath)) - { - Directory.Delete(_basePath, recursive: true); - } - } - catch - { - // Best-effort cleanup for temp test data. - } + Assert.NotNull(restored); + Assert.Equal(created.Version, restored.Version); + Assert.Equal(created.AppVersion, restored.AppVersion); + Assert.True(File.Exists(StorageConfig.DatabasePath)); } #endregion diff --git a/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs b/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs index 9323f5d..d292abb 100644 --- a/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs +++ b/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs @@ -17,189 +17,138 @@ public CleanupServiceTests() StorageConfig.Initialize(); } - #region RunCleanupIfNeeded Tests + #region Constructor Tests [Fact] - public void RunCleanupIfNeeded_CallsRepository_WhenDue() + public void Constructor_NullRepository_ThrowsArgumentNullException() { - var repo = new StubClipboardRepository(); - using var service = new CleanupService(repo, () => 7, startTimer: false); - - service.RunCleanupIfNeeded(); - - Assert.Equal(1, repo.ClearCalls); - Assert.True(File.Exists(GetLastCleanupFile())); + Assert.Throws(() => + new CleanupService(null!, () => 7, startTimer: false)); } [Fact] - public void RunCleanupIfNeeded_Skips_WhenRetentionIsZero() + public void Constructor_NullGetRetentionDays_ThrowsArgumentNullException() { var repo = new StubClipboardRepository(); - using var service = new CleanupService(repo, () => 0, startTimer: false); - - service.RunCleanupIfNeeded(); - - Assert.Equal(0, repo.ClearCalls); - Assert.False(File.Exists(GetLastCleanupFile())); + Assert.Throws(() => + new CleanupService(repo, null!, startTimer: false)); } [Fact] - public void RunCleanupIfNeeded_Skips_WhenAlreadyCleanedToday() + public void Constructor_WithTimerDisabled_DoesNotRunCleanupImmediately() { var repo = new StubClipboardRepository(); - File.WriteAllText(GetLastCleanupFile(), DateTime.UtcNow.ToString("O")); using var service = new CleanupService(repo, () => 7, startTimer: false); - service.RunCleanupIfNeeded(); + Thread.Sleep(100); Assert.Equal(0, repo.ClearCalls); } - [Fact] - public void RunCleanupIfNeeded_Runs_WhenLastCleanupIsOld() - { - var repo = new StubClipboardRepository(); - var yesterday = DateTime.UtcNow.AddDays(-1); - File.WriteAllText(GetLastCleanupFile(), yesterday.ToString("O")); - using var service = new CleanupService(repo, () => 7, startTimer: false); - - service.RunCleanupIfNeeded(); + #endregion - Assert.Equal(1, repo.ClearCalls); - } + #region Dispose Tests [Fact] - public void RunCleanupIfNeeded_Skips_WhenNegativeRetention() + public void Dispose_CanBeCalledOnce() { var repo = new StubClipboardRepository(); - using var service = new CleanupService(repo, () => -1, startTimer: false); + var service = new CleanupService(repo, () => 7, startTimer: false); - service.RunCleanupIfNeeded(); + service.Dispose(); - Assert.Equal(0, repo.ClearCalls); - Assert.False(File.Exists(GetLastCleanupFile())); + Assert.True(true); } [Fact] - public void RunCleanupIfNeeded_HandlesInvalidLastCleanupFile() + public void Dispose_CanBeCalledMultipleTimes() { var repo = new StubClipboardRepository(); - File.WriteAllText(GetLastCleanupFile(), "invalid-date"); - using var service = new CleanupService(repo, () => 7, startTimer: false); + var service = new CleanupService(repo, () => 7, startTimer: false); - service.RunCleanupIfNeeded(); + service.Dispose(); + service.Dispose(); + service.Dispose(); - Assert.Equal(1, repo.ClearCalls); - Assert.True(File.Exists(GetLastCleanupFile())); + Assert.True(true); } - [Theory] - [InlineData(1)] - [InlineData(7)] - [InlineData(30)] - [InlineData(90)] - [InlineData(365)] - public void RunCleanupIfNeeded_PassesCorrectRetentionDays(int days) + #endregion + + #region RunCleanupIfNeeded Tests + + [Fact] + public void RunCleanupIfNeeded_RetentionDaysZero_DoesNotCallRepository() { var repo = new StubClipboardRepository(); - using var service = new CleanupService(repo, () => days, startTimer: false); + using var service = new CleanupService(repo, () => 0, startTimer: false); service.RunCleanupIfNeeded(); - Assert.Equal(1, repo.ClearCalls); - Assert.Equal(days, repo.LastRetentionDays); + Assert.Equal(0, repo.ClearCalls); } [Fact] - public void RunCleanupIfNeeded_CreatesDirectoryIfNotExists() + public void RunCleanupIfNeeded_RetentionDaysNegative_DoesNotCallRepository() { var repo = new StubClipboardRepository(); - var directory = Path.GetDirectoryName(StorageConfig.DatabasePath)!; - if (Directory.Exists(directory)) - { - Directory.Delete(directory, true); - } + using var service = new CleanupService(repo, () => -1, startTimer: false); - using var service = new CleanupService(repo, () => 7, startTimer: false); service.RunCleanupIfNeeded(); - // Wait longer and retry for filesystem operations to complete - var cleanupFile = GetLastCleanupFile(); - for (int i = 0; i < 20; i++) - { - if (Directory.Exists(directory) && File.Exists(cleanupFile)) - break; - Thread.Sleep(100); - } - - // Use cleanup file existence as proof that directory was created - Assert.True(File.Exists(cleanupFile), - $"Cleanup file should exist: {cleanupFile} (directory: {directory}, exists: {Directory.Exists(directory)})"); + Assert.Equal(0, repo.ClearCalls); } [Fact] - public void RunCleanupIfNeeded_UpdatesLastCleanupFile() + public void RunCleanupIfNeeded_NoPreviousCleanup_CallsRepository() { var repo = new StubClipboardRepository(); - var beforeTime = DateTime.UtcNow.AddSeconds(-1); // Add tolerance - using var service = new CleanupService(repo, () => 7, startTimer: false); - service.RunCleanupIfNeeded(); - var afterTime = DateTime.UtcNow.AddSeconds(1); // Add tolerance - var fileContent = File.ReadAllText(GetLastCleanupFile()); - var lastCleanupTime = DateTime.Parse(fileContent, System.Globalization.CultureInfo.InvariantCulture).ToUniversalTime(); + service.RunCleanupIfNeeded(); - Assert.True(lastCleanupTime >= beforeTime && lastCleanupTime <= afterTime, - $"Cleanup time {lastCleanupTime:O} should be between {beforeTime:O} and {afterTime:O}"); + Assert.Equal(1, repo.ClearCalls); } [Fact] - public void RunCleanupIfNeeded_MultipleCallsSameDay_OnlyCleanOnce() + public void RunCleanupIfNeeded_AfterDispose_DoesNothing() { var repo = new StubClipboardRepository(); - using var service = new CleanupService(repo, () => 7, startTimer: false); + var service = new CleanupService(repo, () => 7, startTimer: false); + service.Dispose(); - service.RunCleanupIfNeeded(); - service.RunCleanupIfNeeded(); service.RunCleanupIfNeeded(); - Assert.Equal(1, repo.ClearCalls); + Assert.Equal(0, repo.ClearCalls); } - #endregion - - #region Dispose Tests - [Fact] - public void Dispose_CanBeCalledMultipleTimes() + public void RunCleanupIfNeeded_WritesLastCleanupFile() { var repo = new StubClipboardRepository(); - var service = new CleanupService(repo, () => 7, startTimer: false); + using var service = new CleanupService(repo, () => 7, startTimer: false); - service.Dispose(); - service.Dispose(); - service.Dispose(); + service.RunCleanupIfNeeded(); - // Should not throw - Assert.True(true); + Assert.True(File.Exists(GetLastCleanupFilePath())); } [Fact] - public void Constructor_WithTimerDisabled_DoesNotStartTimer() + public void RunCleanupIfNeeded_SameDay_DoesNotCleanupTwice() { var repo = new StubClipboardRepository(); using var service = new CleanupService(repo, () => 7, startTimer: false); - // Wait a bit to ensure timer doesn't trigger - Thread.Sleep(100); + service.RunCleanupIfNeeded(); + service.RunCleanupIfNeeded(); + service.RunCleanupIfNeeded(); - Assert.Equal(0, repo.ClearCalls); + Assert.Equal(1, repo.ClearCalls); } #endregion - private static string GetLastCleanupFile() + private static string GetLastCleanupFilePath() { var directory = Path.GetDirectoryName(StorageConfig.DatabasePath)!; return Path.Combine(directory, "last_cleanup.txt"); @@ -233,21 +182,13 @@ public int ClearOldItems(int days, bool excludePinned = true) } public void Delete(Guid id) => throw new NotImplementedException(); - public IEnumerable GetAll() => throw new NotImplementedException(); - public ClipboardItem? GetById(Guid id) => throw new NotImplementedException(); - public ClipboardItem? GetLatest() => throw new NotImplementedException(); - public ClipboardItem? FindByContentAndType(string content, ClipboardContentType type) => throw new NotImplementedException(); - public ClipboardItem? FindByContentHash(string contentHash) => throw new NotImplementedException(); - public void Save(ClipboardItem item) => throw new NotImplementedException(); - public IEnumerable Search(string query, int limit = 50, int skip = 0) => throw new NotImplementedException(); - public IEnumerable SearchAdvanced( string? query, IReadOnlyCollection? types, @@ -255,7 +196,6 @@ public IEnumerable SearchAdvanced( bool? isPinned, int limit, int skip) => throw new NotImplementedException(); - public void Update(ClipboardItem item) => throw new NotImplementedException(); } } diff --git a/Tests/CopyPaste.Core.Tests/ClipboardItemTests.cs b/Tests/CopyPaste.Core.Tests/ClipboardItemTests.cs new file mode 100644 index 0000000..2afad71 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/ClipboardItemTests.cs @@ -0,0 +1,325 @@ +using System; +using System.IO; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public sealed class ClipboardItemTests : IDisposable +{ + private readonly string _basePath; + + public ClipboardItemTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(_basePath); + } + + #region Default Values + + [Fact] + public void NewItem_HasNonEmptyId() + { + var item = new ClipboardItem(); + Assert.NotEqual(Guid.Empty, item.Id); + } + + [Fact] + public void NewItem_HasEmptyContent() + { + var item = new ClipboardItem(); + Assert.Equal(string.Empty, item.Content); + } + + [Fact] + public void NewItem_HasDefaultType() + { + var item = new ClipboardItem(); + Assert.Equal(ClipboardContentType.Text, item.Type); + } + + [Fact] + public void NewItem_HasRecentCreatedAt() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var item = new ClipboardItem(); + var after = DateTime.UtcNow.AddSeconds(1); + + Assert.InRange(item.CreatedAt, before, after); + } + + [Fact] + public void NewItem_HasRecentModifiedAt() + { + var before = DateTime.UtcNow.AddSeconds(-1); + var item = new ClipboardItem(); + var after = DateTime.UtcNow.AddSeconds(1); + + Assert.InRange(item.ModifiedAt, before, after); + } + + [Fact] + public void NewItem_IsNotPinned() + { + var item = new ClipboardItem(); + Assert.False(item.IsPinned); + } + + [Fact] + public void NewItem_HasNullLabel() + { + var item = new ClipboardItem(); + Assert.Null(item.Label); + } + + [Fact] + public void NewItem_HasNoCardColor() + { + var item = new ClipboardItem(); + Assert.Equal(CardColor.None, item.CardColor); + } + + [Fact] + public void NewItem_HasNullMetadata() + { + var item = new ClipboardItem(); + Assert.Null(item.Metadata); + } + + [Fact] + public void NewItem_HasZeroPasteCount() + { + var item = new ClipboardItem(); + Assert.Equal(0, item.PasteCount); + } + + [Fact] + public void NewItem_HasNullContentHash() + { + var item = new ClipboardItem(); + Assert.Null(item.ContentHash); + } + + [Fact] + public void NewItem_HasNullAppSource() + { + var item = new ClipboardItem(); + Assert.Null(item.AppSource); + } + + [Fact] + public void MaxLabelLength_Is40() + { + Assert.Equal(40, ClipboardItem.MaxLabelLength); + } + + [Fact] + public void TwoNewItems_HaveDifferentIds() + { + var item1 = new ClipboardItem(); + var item2 = new ClipboardItem(); + Assert.NotEqual(item1.Id, item2.Id); + } + + #endregion + + #region IsFileBasedType Tests + + [Theory] + [InlineData(ClipboardContentType.File, true)] + [InlineData(ClipboardContentType.Folder, true)] + [InlineData(ClipboardContentType.Audio, true)] + [InlineData(ClipboardContentType.Video, true)] + [InlineData(ClipboardContentType.Text, false)] + [InlineData(ClipboardContentType.Image, false)] + [InlineData(ClipboardContentType.Link, false)] + [InlineData(ClipboardContentType.Unknown, false)] + public void IsFileBasedType_ReturnsCorrectValue(ClipboardContentType type, bool expected) + { + var item = new ClipboardItem { Type = type }; + Assert.Equal(expected, item.IsFileBasedType); + } + + #endregion + + #region IsFileAvailable Tests + + [Fact] + public void IsFileAvailable_NonFileType_ReturnsTrue() + { + var item = new ClipboardItem { Type = ClipboardContentType.Text, Content = "any text" }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_ImageType_ReturnsTrue() + { + var item = new ClipboardItem { Type = ClipboardContentType.Image, Content = "some/path.png" }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_LinkType_ReturnsTrue() + { + var item = new ClipboardItem { Type = ClipboardContentType.Link, Content = "https://example.com" }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FileType_WithEmptyContent_ReturnsFalse() + { + var item = new ClipboardItem { Type = ClipboardContentType.File, Content = string.Empty }; + Assert.False(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FileType_WithNullContent_ReturnsFalse() + { + var item = new ClipboardItem { Type = ClipboardContentType.File, Content = null! }; + Assert.False(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FileType_WithExistingFile_ReturnsTrue() + { + var filePath = Path.Combine(_basePath, "test.txt"); + File.WriteAllText(filePath, "content"); + + var item = new ClipboardItem { Type = ClipboardContentType.File, Content = filePath }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FileType_WithNonExistentFile_ReturnsFalse() + { + var filePath = Path.Combine(_basePath, "nonexistent.txt"); + var item = new ClipboardItem { Type = ClipboardContentType.File, Content = filePath }; + Assert.False(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FolderType_WithExistingFolder_ReturnsTrue() + { + var folderPath = Path.Combine(_basePath, "testFolder"); + Directory.CreateDirectory(folderPath); + + var item = new ClipboardItem { Type = ClipboardContentType.Folder, Content = folderPath }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FolderType_WithNonExistentFolder_ReturnsFalse() + { + var folderPath = Path.Combine(_basePath, "nonexistentFolder"); + var item = new ClipboardItem { Type = ClipboardContentType.Folder, Content = folderPath }; + Assert.False(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_AudioType_WithExistingFile_ReturnsTrue() + { + var filePath = Path.Combine(_basePath, "song.mp3"); + File.WriteAllText(filePath, "fake audio"); + + var item = new ClipboardItem { Type = ClipboardContentType.Audio, Content = filePath }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_VideoType_WithExistingFile_ReturnsTrue() + { + var filePath = Path.Combine(_basePath, "video.mp4"); + File.WriteAllText(filePath, "fake video"); + + var item = new ClipboardItem { Type = ClipboardContentType.Video, Content = filePath }; + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_MultipleFiles_ChecksFirstPathOnly() + { + var existingFile = Path.Combine(_basePath, "first.txt"); + File.WriteAllText(existingFile, "content"); + var nonExisting = Path.Combine(_basePath, "second.txt"); + + var content = existingFile + Environment.NewLine + nonExisting; + var item = new ClipboardItem { Type = ClipboardContentType.File, Content = content }; + + Assert.True(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_MultipleFiles_FirstDoesNotExist_ReturnsFalse() + { + var nonExisting = Path.Combine(_basePath, "first_missing.txt"); + var existingFile = Path.Combine(_basePath, "second.txt"); + File.WriteAllText(existingFile, "content"); + + var content = nonExisting + Environment.NewLine + existingFile; + var item = new ClipboardItem { Type = ClipboardContentType.File, Content = content }; + + Assert.False(item.IsFileAvailable()); + } + + [Fact] + public void IsFileAvailable_FileType_OnlyNewlines_ReturnsFalse() + { + var item = new ClipboardItem + { + Type = ClipboardContentType.File, + Content = Environment.NewLine + Environment.NewLine + }; + Assert.False(item.IsFileAvailable()); + } + + #endregion + + #region Property Setters + + [Fact] + public void Properties_CanBeSet() + { + var id = Guid.NewGuid(); + var now = DateTime.UtcNow; + + var item = new ClipboardItem + { + Id = id, + Content = "test content", + Type = ClipboardContentType.Link, + CreatedAt = now, + ModifiedAt = now, + AppSource = "Chrome", + IsPinned = true, + Label = "My Label", + CardColor = CardColor.Blue, + Metadata = "{\"key\":\"value\"}", + PasteCount = 5, + ContentHash = "abc123" + }; + + Assert.Equal(id, item.Id); + Assert.Equal("test content", item.Content); + Assert.Equal(ClipboardContentType.Link, item.Type); + Assert.Equal(now, item.CreatedAt); + Assert.Equal(now, item.ModifiedAt); + Assert.Equal("Chrome", item.AppSource); + Assert.True(item.IsPinned); + Assert.Equal("My Label", item.Label); + Assert.Equal(CardColor.Blue, item.CardColor); + Assert.Equal("{\"key\":\"value\"}", item.Metadata); + Assert.Equal(5, item.PasteCount); + Assert.Equal("abc123", item.ContentHash); + } + + #endregion + + public void Dispose() + { + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } + } +} diff --git a/Tests/CopyPaste.Core.Tests/ClipboardServiceAdditionalTests.cs b/Tests/CopyPaste.Core.Tests/ClipboardServiceAdditionalTests.cs new file mode 100644 index 0000000..45bbd27 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/ClipboardServiceAdditionalTests.cs @@ -0,0 +1,496 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public sealed class ClipboardServiceAdditionalTests : IDisposable +{ + private readonly string _basePath; + private readonly StubClipboardRepository _repository; + private readonly ClipboardService _service; + + public ClipboardServiceAdditionalTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(_basePath); + StorageConfig.Initialize(); + + _repository = new StubClipboardRepository(); + _service = new ClipboardService(_repository); + } + + #region ConvertDibToBmp via AddImage + + [Fact] + public void AddImage_WithValidDibData_SavesItem() + { + byte[] dibData = CreateValidDibData(2, 2, 24); + + _service.AddImage(dibData, "TestApp"); + + Assert.Single(_repository.SavedItems); + Assert.Equal(ClipboardContentType.Image, _repository.SavedItems[0].Type); + } + + [Fact] + public void AddImage_WithValidDibData_SetsContentHash() + { + byte[] dibData = CreateValidDibData(2, 2, 24); + + _service.AddImage(dibData, "TestApp"); + + Assert.Single(_repository.SavedItems); + var hash = _repository.SavedItems[0].ContentHash; + Assert.NotNull(hash); + Assert.NotEmpty(hash); + } + + [Fact] + public void AddImage_WithValidDibData_SetsMetadataWithHash() + { + byte[] dibData = CreateValidDibData(2, 2, 24); + + _service.AddImage(dibData, "TestApp"); + + Assert.Single(_repository.SavedItems); + Assert.NotNull(_repository.SavedItems[0].Metadata); + Assert.Contains("hash", _repository.SavedItems[0].Metadata, StringComparison.Ordinal); + } + + [Fact] + public void AddImage_DibDataTooShort_DoesNotSave() + { + _service.AddImage(new byte[39], "TestApp"); + + Assert.Empty(_repository.SavedItems); + } + + [Fact] + public void AddImage_With8BitDib_HandlesColorPalette() + { + byte[] dibData = CreateValidDibData(2, 2, 8); + + _service.AddImage(dibData, "TestApp"); + + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddImage_WithCompression3_HandlesBitmasks() + { + byte[] dibData = CreateValidDibData(2, 2, 32, compression: 3); + + _service.AddImage(dibData, "TestApp"); + + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddImage_SameImageTwice_ReactivatesExisting() + { + byte[] dibData = CreateValidDibData(1, 1, 24); + + _service.AddImage(dibData, "TestApp"); + Assert.Single(_repository.SavedItems); + + var savedHash = _repository.SavedItems[0].ContentHash; + _repository.ItemsByHash[savedHash!] = _repository.SavedItems[0]; + + ClipboardItem? reactivated = null; + _service.OnItemReactivated += item => reactivated = item; + + _service.AddImage(dibData, "TestApp"); + + Assert.NotNull(reactivated); + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddImage_FiresOnItemAddedEvent() + { + ClipboardItem? added = null; + _service.OnItemAdded += item => added = item; + + byte[] dibData = CreateValidDibData(1, 1, 24); + _service.AddImage(dibData, "TestApp"); + + Assert.NotNull(added); + Assert.Equal(ClipboardContentType.Image, added.Type); + } + + [Fact] + public void AddImage_SetsAppSource() + { + byte[] dibData = CreateValidDibData(1, 1, 24); + _service.AddImage(dibData, "Photoshop"); + + Assert.Single(_repository.SavedItems); + Assert.Equal("Photoshop", _repository.SavedItems[0].AppSource); + } + + #endregion + + #region GetHistory and GetHistoryAdvanced + + [Fact] + public void GetHistory_WithDefaultParams_CallsRepository() + { + var items = _service.GetHistory().ToList(); + Assert.Empty(items); + } + + [Fact] + public void GetHistory_PassesParametersCorrectly() + { + _repository.SearchAdvancedCallback = (query, types, colors, isPinned, limit, skip) => + { + Assert.Equal("search", query); + Assert.Null(types); + Assert.Null(colors); + Assert.True(isPinned); + Assert.Equal(10, limit); + Assert.Equal(5, skip); + }; + + var result = _service.GetHistory(10, 5, "search", true).ToList(); + Assert.NotNull(result); + } + + [Fact] + public void GetHistoryAdvanced_PassesAllParameters() + { + var typeFilter = new List { ClipboardContentType.Text, ClipboardContentType.Link }; + var colorFilter = new List { CardColor.Red }; + + _repository.SearchAdvancedCallback = (query, types, colors, isPinned, limit, skip) => + { + Assert.Equal("test", query); + Assert.NotNull(types); + Assert.Equal(2, types!.Count); + Assert.NotNull(colors); + Assert.Single(colors!); + Assert.False(isPinned); + Assert.Equal(20, limit); + Assert.Equal(0, skip); + }; + + var result = _service.GetHistoryAdvanced(20, 0, "test", typeFilter, colorFilter, false).ToList(); + Assert.NotNull(result); + } + + [Fact] + public void GetHistoryAdvanced_WithNullFilters_PassesNulls() + { + _repository.SearchAdvancedCallback = (query, types, colors, isPinned, limit, skip) => + { + Assert.Null(query); + Assert.Null(types); + Assert.Null(colors); + Assert.Null(isPinned); + }; + + var result = _service.GetHistoryAdvanced(50, 0, null, null, null, null).ToList(); + Assert.NotNull(result); + } + + #endregion + + #region AddText with HTML bytes + + [Fact] + public void AddText_WithHtmlBytes_SavesMetadata() + { + var htmlBytes = new byte[] { 60, 104, 49, 62, 72, 105, 60, 47, 104, 49, 62 }; //

Hi

+ _service.AddText("Hi", ClipboardContentType.Text, "TestApp", htmlBytes: htmlBytes); + + Assert.Single(_repository.SavedItems); + Assert.NotNull(_repository.SavedItems[0].Metadata); + Assert.Contains("html", _repository.SavedItems[0].Metadata, StringComparison.Ordinal); + } + + [Fact] + public void AddText_WithBothRtfAndHtml_SavesBothInMetadata() + { + var rtfBytes = new byte[] { 1, 2, 3 }; + var htmlBytes = new byte[] { 4, 5, 6 }; + _service.AddText("Test", ClipboardContentType.Text, "TestApp", rtfBytes, htmlBytes); + + Assert.Single(_repository.SavedItems); + var metadata = _repository.SavedItems[0].Metadata!; + Assert.Contains("rtf", metadata, StringComparison.Ordinal); + Assert.Contains("html", metadata, StringComparison.Ordinal); + } + + #endregion + + #region AddFiles with metadata details + + [Fact] + public void AddFiles_WithSingleFile_IncludesFileExtensionInMetadata() + { + var testFile = Path.Combine(_basePath, "document.pdf"); + File.WriteAllText(testFile, "fake pdf"); + + _service.AddFiles(new Collection { testFile }, ClipboardContentType.File, "Explorer"); + + Assert.Single(_repository.SavedItems); + Assert.Contains(".pdf", _repository.SavedItems[0].Metadata!, StringComparison.Ordinal); + } + + [Fact] + public void AddFiles_WithSingleFile_IncludesFileNameInMetadata() + { + var testFile = Path.Combine(_basePath, "readme.txt"); + File.WriteAllText(testFile, "content"); + + _service.AddFiles(new Collection { testFile }, ClipboardContentType.File, "Explorer"); + + Assert.Contains("readme.txt", _repository.SavedItems[0].Metadata!, StringComparison.Ordinal); + } + + [Fact] + public void AddFiles_WithDirectory_DoesNotIncludeFileSize() + { + var testDir = Path.Combine(_basePath, "myFolder"); + Directory.CreateDirectory(testDir); + + _service.AddFiles(new Collection { testDir }, ClipboardContentType.Folder, "Explorer"); + + Assert.DoesNotContain("file_size", _repository.SavedItems[0].Metadata!, StringComparison.Ordinal); + } + + [Fact] + public void AddFiles_MetadataIsValidJson() + { + var testFile = Path.Combine(_basePath, "jsontest.txt"); + File.WriteAllText(testFile, "test"); + + _service.AddFiles(new Collection { testFile }, ClipboardContentType.File, "Explorer"); + + var metadata = _repository.SavedItems[0].Metadata!; + using var doc = JsonDocument.Parse(metadata); + Assert.NotNull(doc); + } + + [Fact] + public void AddFiles_WithThreeFiles_FileCountIsCorrect() + { + var files = new Collection(); + for (int i = 0; i < 3; i++) + { + var path = Path.Combine(_basePath, $"file{i}.txt"); + File.WriteAllText(path, "content"); + files.Add(path); + } + + _service.AddFiles(files, ClipboardContentType.File, "Explorer"); + + Assert.Contains("3", _repository.SavedItems[0].Metadata!, StringComparison.Ordinal); + } + + #endregion + + #region Duplicate Detection for Non-Image Types + + [Fact] + public void AddText_DuplicateByContentAndType_ReactivatesItem() + { + var existing = new ClipboardItem + { + Id = Guid.NewGuid(), + Content = "Same content", + Type = ClipboardContentType.Text + }; + _repository.ByContentAndType[(existing.Content, existing.Type)] = existing; + + ClipboardItem? reactivated = null; + _service.OnItemReactivated += item => reactivated = item; + + _service.AddText("Same content", ClipboardContentType.Text, "App"); + + Assert.NotNull(reactivated); + Assert.Equal(existing.Id, reactivated.Id); + Assert.Empty(_repository.SavedItems); + } + + [Fact] + public void AddText_SameContentDifferentType_SavesNewItem() + { + var existing = new ClipboardItem + { + Id = Guid.NewGuid(), + Content = "https://example.com", + Type = ClipboardContentType.Text + }; + _repository.ByContentAndType[(existing.Content, existing.Type)] = existing; + + _service.AddText("https://example.com", ClipboardContentType.Link, "App"); + + Assert.Single(_repository.SavedItems); + } + + #endregion + + #region RemoveItem and UpdatePin edge cases + + [Fact] + public void RemoveItem_ExistingItem_CallsDelete() + { + var id = Guid.NewGuid(); + _repository.ItemsById[id] = new ClipboardItem { Id = id, Content = "test" }; + + _service.RemoveItem(id); + + Assert.Single(_repository.DeletedIds); + } + + [Fact] + public void RemoveItem_NonExistent_NoDelete() + { + _service.RemoveItem(Guid.NewGuid()); + Assert.Empty(_repository.DeletedIds); + } + + [Fact] + public void UpdatePin_ExistingItem_PinsSuccessfully() + { + var id = Guid.NewGuid(); + var item = new ClipboardItem { Id = id, Content = "test", IsPinned = false }; + _repository.ItemsById[id] = item; + + _service.UpdatePin(id, true); + + Assert.True(item.IsPinned); + } + + [Fact] + public void UpdateLabelAndColor_SetsValuesCorrectly() + { + var id = Guid.NewGuid(); + var item = new ClipboardItem { Id = id, Content = "test" }; + _repository.ItemsById[id] = item; + + _service.UpdateLabelAndColor(id, "Important", CardColor.Red); + + Assert.Equal("Important", item.Label); + Assert.Equal(CardColor.Red, item.CardColor); + } + + [Fact] + public void MarkItemUsed_ReturnsUpdatedItem() + { + var id = Guid.NewGuid(); + var item = new ClipboardItem { Id = id, Content = "test", PasteCount = 3 }; + _repository.ItemsById[id] = item; + + var result = _service.MarkItemUsed(id); + + Assert.NotNull(result); + Assert.Equal(4, result.PasteCount); + } + + #endregion + + #region PasteIgnoreWindowMs + + [Fact] + public void PasteIgnoreWindowMs_DefaultIs450() + { + Assert.Equal(450, _service.PasteIgnoreWindowMs); + } + + [Fact] + public void PasteIgnoreWindowMs_CanBeSet() + { + _service.PasteIgnoreWindowMs = 1000; + Assert.Equal(1000, _service.PasteIgnoreWindowMs); + } + + #endregion + + private static byte[] CreateValidDibData(int width, int height, int bitCount, int compression = 0) + { + int headerSize = 40; + int bytesPerPixel = bitCount / 8; + int rowSize = ((width * bitCount + 31) / 32) * 4; + int pixelDataSize = rowSize * height; + + int paletteSize = 0; + if (compression == 3) paletteSize = 12; + else if (bitCount <= 8) paletteSize = (1 << bitCount) * 4; + + byte[] data = new byte[headerSize + paletteSize + pixelDataSize]; + + BitConverter.GetBytes(headerSize).CopyTo(data, 0); + BitConverter.GetBytes(width).CopyTo(data, 4); + BitConverter.GetBytes(height).CopyTo(data, 8); + BitConverter.GetBytes((short)1).CopyTo(data, 12); + BitConverter.GetBytes((short)bitCount).CopyTo(data, 14); + BitConverter.GetBytes(compression).CopyTo(data, 16); + BitConverter.GetBytes(pixelDataSize).CopyTo(data, 20); + + if (compression == 3) + { + BitConverter.GetBytes(0x00FF0000).CopyTo(data, headerSize); + BitConverter.GetBytes(0x0000FF00).CopyTo(data, headerSize + 4); + BitConverter.GetBytes(0x000000FF).CopyTo(data, headerSize + 8); + } + + return data; + } + + public void Dispose() + { + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } + } + + private sealed class StubClipboardRepository : IClipboardRepository + { + public List SavedItems { get; } = []; + public List UpdatedItems { get; } = []; + public Dictionary ItemsById { get; } = []; + public Dictionary ItemsByHash { get; } = []; + public Dictionary<(string, ClipboardContentType), ClipboardItem> ByContentAndType { get; } = []; + public List DeletedIds { get; } = []; + + public Action?, IReadOnlyCollection?, bool?, int, int>? SearchAdvancedCallback { get; set; } + + public void Save(ClipboardItem item) => SavedItems.Add(item); + public void Update(ClipboardItem item) => UpdatedItems.Add(item); + public ClipboardItem? GetById(Guid id) => ItemsById.GetValueOrDefault(id); + public ClipboardItem? GetLatest() => null; + + public ClipboardItem? FindByContentAndType(string content, ClipboardContentType type) => + ByContentAndType.GetValueOrDefault((content, type)); + + public ClipboardItem? FindByContentHash(string contentHash) => + ItemsByHash.GetValueOrDefault(contentHash); + + public IEnumerable GetAll() => []; + public void Delete(Guid id) => DeletedIds.Add(id); + public int ClearOldItems(int days, bool excludePinned = true) => 0; + public IEnumerable Search(string query, int limit = 50, int skip = 0) => []; + + public IEnumerable SearchAdvanced( + string? query, + IReadOnlyCollection? types, + IReadOnlyCollection? colors, + bool? isPinned, + int limit, + int skip) + { + SearchAdvancedCallback?.Invoke(query, types, colors, isPinned, limit, skip); + return []; + } + } +} diff --git a/Tests/CopyPaste.Core.Tests/ConfigLoaderTests.cs b/Tests/CopyPaste.Core.Tests/ConfigLoaderTests.cs new file mode 100644 index 0000000..7e3151c --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/ConfigLoaderTests.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public sealed class ConfigLoaderTests : IDisposable +{ + private readonly string _basePath; + + public ConfigLoaderTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(_basePath); + StorageConfig.Initialize(); + ConfigLoader.ClearCache(); + } + + #region Load Tests + + [Fact] + public void Load_WithNoConfigFile_ReturnsDefaultConfig() + { + var config = ConfigLoader.Load(); + + Assert.NotNull(config); + Assert.Equal("auto", config.PreferredLanguage); + Assert.True(config.RunOnStartup); + } + + [Fact] + public void Load_WithValidConfigFile_ReturnsDeserializedConfig() + { + var config = new MyMConfig { PreferredLanguage = "es-CL", RunOnStartup = false, PageSize = 50 }; + var json = JsonSerializer.Serialize(config, MyMConfigJsonContext.Default.MyMConfig); + Directory.CreateDirectory(StorageConfig.ConfigPath); + File.WriteAllText(ConfigLoader.ConfigFilePath, json); + + ConfigLoader.ClearCache(); + var loaded = ConfigLoader.Load(); + + Assert.Equal("es-CL", loaded.PreferredLanguage); + Assert.False(loaded.RunOnStartup); + Assert.Equal(50, loaded.PageSize); + } + + [Fact] + public void Load_CachesResult_ReturnsSameInstance() + { + var first = ConfigLoader.Load(); + var second = ConfigLoader.Load(); + + Assert.Same(first, second); + } + + [Fact] + public void Load_WithCorruptedJson_ReturnsDefaultConfig() + { + Directory.CreateDirectory(StorageConfig.ConfigPath); + File.WriteAllText(ConfigLoader.ConfigFilePath, "{ invalid json!!!"); + + ConfigLoader.ClearCache(); + var config = ConfigLoader.Load(); + + Assert.NotNull(config); + Assert.Equal("auto", config.PreferredLanguage); + } + + [Fact] + public void Config_Property_LoadsOnFirstAccess() + { + ConfigLoader.ClearCache(); + var config = ConfigLoader.Config; + + Assert.NotNull(config); + } + + [Fact] + public void Config_Property_ReturnsCachedInstance() + { + ConfigLoader.ClearCache(); + var first = ConfigLoader.Config; + var second = ConfigLoader.Config; + + Assert.Same(first, second); + } + + #endregion + + #region Save Tests + + [Fact] + public void Save_WritesConfigFile_ReturnsTrue() + { + var config = new MyMConfig { PreferredLanguage = "en-US", PageSize = 99 }; + + var result = ConfigLoader.Save(config); + + Assert.True(result); + Assert.True(File.Exists(ConfigLoader.ConfigFilePath)); + } + + [Fact] + public void Save_FileContainsSerializedConfig() + { + var config = new MyMConfig { PreferredLanguage = "es-CL", RetentionDays = 60 }; + + ConfigLoader.Save(config); + + var json = File.ReadAllText(ConfigLoader.ConfigFilePath); + Assert.Contains("es-CL", json, StringComparison.Ordinal); + } + + [Fact] + public void Save_UpdatesCache() + { + var config = new MyMConfig { PreferredLanguage = "es-CL" }; + + ConfigLoader.Save(config); + + Assert.Same(config, ConfigLoader.Config); + } + + [Fact] + public void Save_CreatesDirectoryIfNotExists() + { + var newPath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(newPath); + + var config = new MyMConfig(); + var result = ConfigLoader.Save(config); + + Assert.True(result); + Assert.True(Directory.Exists(StorageConfig.ConfigPath)); + + try { Directory.Delete(newPath, true); } catch { } + } + + #endregion + + #region ClearCache Tests + + [Fact] + public void ClearCache_ForcesReloadOnNextAccess() + { + var first = ConfigLoader.Config; + ConfigLoader.ClearCache(); + + var config = new MyMConfig { PageSize = 77 }; + var json = JsonSerializer.Serialize(config, MyMConfigJsonContext.Default.MyMConfig); + File.WriteAllText(ConfigLoader.ConfigFilePath, json); + + var reloaded = ConfigLoader.Config; + + Assert.Equal(77, reloaded.PageSize); + } + + #endregion + + #region GetColorLabel Tests + + [Fact] + public void GetColorLabel_String_WithConfiguredLabel_ReturnsLabel() + { + var config = new MyMConfig + { + ColorLabels = new Dictionary { { "Red", "Urgent" } } + }; + ConfigLoader.Save(config); + ConfigLoader.ClearCache(); + + var label = ConfigLoader.GetColorLabel("Red"); + + Assert.Equal("Urgent", label); + } + + [Fact] + public void GetColorLabel_String_WithNoLabels_ReturnsNull() + { + var config = new MyMConfig { ColorLabels = null }; + ConfigLoader.Save(config); + ConfigLoader.ClearCache(); + + var label = ConfigLoader.GetColorLabel("Red"); + + Assert.Null(label); + } + + [Fact] + public void GetColorLabel_String_WithEmptyLabel_ReturnsNull() + { + var config = new MyMConfig + { + ColorLabels = new Dictionary { { "Red", " " } } + }; + ConfigLoader.Save(config); + ConfigLoader.ClearCache(); + + var label = ConfigLoader.GetColorLabel("Red"); + + Assert.Null(label); + } + + [Fact] + public void GetColorLabel_String_WithMissingColor_ReturnsNull() + { + var config = new MyMConfig + { + ColorLabels = new Dictionary { { "Red", "Urgent" } } + }; + ConfigLoader.Save(config); + ConfigLoader.ClearCache(); + + var label = ConfigLoader.GetColorLabel("Blue"); + + Assert.Null(label); + } + + [Fact] + public void GetColorLabel_CardColor_WithConfiguredLabel_ReturnsLabel() + { + var config = new MyMConfig + { + ColorLabels = new Dictionary { { "Green", "Personal" } } + }; + ConfigLoader.Save(config); + ConfigLoader.ClearCache(); + + var label = ConfigLoader.GetColorLabel(CardColor.Green); + + Assert.Equal("Personal", label); + } + + [Fact] + public void GetColorLabel_CardColorNone_ReturnsNull() + { + var label = ConfigLoader.GetColorLabel(CardColor.None); + + Assert.Null(label); + } + + [Theory] + [InlineData(CardColor.Red)] + [InlineData(CardColor.Green)] + [InlineData(CardColor.Purple)] + [InlineData(CardColor.Yellow)] + [InlineData(CardColor.Blue)] + [InlineData(CardColor.Orange)] + public void GetColorLabel_AllNonNoneColors_DoNotThrow(CardColor color) + { + var ex = Record.Exception(() => ConfigLoader.GetColorLabel(color)); + Assert.Null(ex); + } + + #endregion + + #region ConfigFilePath and ConfigFileExists Tests + + [Fact] + public void ConfigFilePath_ContainsConfigDirectory() + { + Assert.Contains(StorageConfig.ConfigPath, ConfigLoader.ConfigFilePath, StringComparison.Ordinal); + } + + [Fact] + public void ConfigFilePath_EndsWithMyMJson() + { + Assert.EndsWith("MyM.json", ConfigLoader.ConfigFilePath, StringComparison.Ordinal); + } + + [Fact] + public void ConfigFileExists_ReturnsFalse_WhenNoFile() + { + Assert.False(ConfigLoader.ConfigFileExists); + } + + [Fact] + public void ConfigFileExists_ReturnsTrue_WhenFileExists() + { + Directory.CreateDirectory(StorageConfig.ConfigPath); + File.WriteAllText(ConfigLoader.ConfigFilePath, "{}"); + + Assert.True(ConfigLoader.ConfigFileExists); + } + + #endregion + + public void Dispose() + { + ConfigLoader.ClearCache(); + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } + } +} diff --git a/Tests/CopyPaste.Core.Tests/CopyPaste.Core.Tests.csproj b/Tests/CopyPaste.Core.Tests/CopyPaste.Core.Tests.csproj index 318a9c6..ef701b3 100644 --- a/Tests/CopyPaste.Core.Tests/CopyPaste.Core.Tests.csproj +++ b/Tests/CopyPaste.Core.Tests/CopyPaste.Core.Tests.csproj @@ -1,6 +1,7 @@ net10.0-windows10.0.22621.0 + x64;ARM64 false enable diff --git a/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs b/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs new file mode 100644 index 0000000..8080524 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs @@ -0,0 +1,331 @@ +using System.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public class MyMConfigTests +{ + #region Default Values + + [Fact] + public void DefaultConfig_PreferredLanguage_IsAuto() + { + var config = new MyMConfig(); + Assert.Equal("auto", config.PreferredLanguage); + } + + [Fact] + public void DefaultConfig_RunOnStartup_IsTrue() + { + var config = new MyMConfig(); + Assert.True(config.RunOnStartup); + } + + [Fact] + public void DefaultConfig_ThemeId_IsDefault() + { + var config = new MyMConfig(); + Assert.Equal("copypaste.default", config.ThemeId); + } + + [Fact] + public void DefaultConfig_UseCtrlKey_IsFalse() + { + var config = new MyMConfig(); + Assert.False(config.UseCtrlKey); + } + + [Fact] + public void DefaultConfig_UseWinKey_IsTrue() + { + var config = new MyMConfig(); + Assert.True(config.UseWinKey); + } + + [Fact] + public void DefaultConfig_UseAltKey_IsTrue() + { + var config = new MyMConfig(); + Assert.True(config.UseAltKey); + } + + [Fact] + public void DefaultConfig_UseShiftKey_IsFalse() + { + var config = new MyMConfig(); + Assert.False(config.UseShiftKey); + } + + [Fact] + public void DefaultConfig_VirtualKey_Is0x56() + { + var config = new MyMConfig(); + Assert.Equal(0x56u, config.VirtualKey); + } + + [Fact] + public void DefaultConfig_KeyName_IsV() + { + var config = new MyMConfig(); + Assert.Equal("V", config.KeyName); + } + + [Fact] + public void DefaultConfig_PageSize_Is30() + { + var config = new MyMConfig(); + Assert.Equal(30, config.PageSize); + } + + [Fact] + public void DefaultConfig_MaxItemsBeforeCleanup_Is100() + { + var config = new MyMConfig(); + Assert.Equal(100, config.MaxItemsBeforeCleanup); + } + + [Fact] + public void DefaultConfig_ScrollLoadThreshold_Is400() + { + var config = new MyMConfig(); + Assert.Equal(400, config.ScrollLoadThreshold); + } + + [Fact] + public void DefaultConfig_ColorLabels_IsNull() + { + var config = new MyMConfig(); + Assert.Null(config.ColorLabels); + } + + [Fact] + public void DefaultConfig_RetentionDays_Is30() + { + var config = new MyMConfig(); + Assert.Equal(30, config.RetentionDays); + } + + [Fact] + public void DefaultConfig_LastBackupDateUtc_IsNull() + { + var config = new MyMConfig(); + Assert.Null(config.LastBackupDateUtc); + } + + [Fact] + public void DefaultConfig_DuplicateIgnoreWindowMs_Is450() + { + var config = new MyMConfig(); + Assert.Equal(450, config.DuplicateIgnoreWindowMs); + } + + [Fact] + public void DefaultConfig_DelayBeforeFocusMs_Is100() + { + var config = new MyMConfig(); + Assert.Equal(100, config.DelayBeforeFocusMs); + } + + [Fact] + public void DefaultConfig_DelayBeforePasteMs_Is180() + { + var config = new MyMConfig(); + Assert.Equal(180, config.DelayBeforePasteMs); + } + + [Fact] + public void DefaultConfig_MaxFocusVerifyAttempts_Is15() + { + var config = new MyMConfig(); + Assert.Equal(15, config.MaxFocusVerifyAttempts); + } + + [Fact] + public void DefaultConfig_ThumbnailWidth_Is170() + { + var config = new MyMConfig(); + Assert.Equal(170, config.ThumbnailWidth); + } + + [Fact] + public void DefaultConfig_ThumbnailQualityPng_Is80() + { + var config = new MyMConfig(); + Assert.Equal(80, config.ThumbnailQualityPng); + } + + [Fact] + public void DefaultConfig_ThumbnailQualityJpeg_Is80() + { + var config = new MyMConfig(); + Assert.Equal(80, config.ThumbnailQualityJpeg); + } + + [Fact] + public void DefaultConfig_ThumbnailGCThreshold_Is1000000() + { + var config = new MyMConfig(); + Assert.Equal(1_000_000, config.ThumbnailGCThreshold); + } + + [Fact] + public void DefaultConfig_ThumbnailUIDecodeHeight_Is95() + { + var config = new MyMConfig(); + Assert.Equal(95, config.ThumbnailUIDecodeHeight); + } + + #endregion + + #region Property Setters + + [Fact] + public void AllProperties_CanBeModified() + { + var config = new MyMConfig + { + PreferredLanguage = "fr-FR", + RunOnStartup = false, + ThemeId = "custom.theme", + UseCtrlKey = true, + UseWinKey = false, + UseAltKey = false, + UseShiftKey = true, + VirtualKey = 0x43, + KeyName = "C", + PageSize = 50, + MaxItemsBeforeCleanup = 200, + ScrollLoadThreshold = 600, + ColorLabels = new Dictionary { { "Red", "Urgent" } }, + RetentionDays = 90, + LastBackupDateUtc = new System.DateTime(2025, 1, 1, 0, 0, 0, System.DateTimeKind.Utc), + DuplicateIgnoreWindowMs = 600, + DelayBeforeFocusMs = 200, + DelayBeforePasteMs = 300, + MaxFocusVerifyAttempts = 20, + ThumbnailWidth = 200, + ThumbnailQualityPng = 90, + ThumbnailQualityJpeg = 70, + ThumbnailGCThreshold = 2_000_000, + ThumbnailUIDecodeHeight = 120 + }; + + Assert.Equal("fr-FR", config.PreferredLanguage); + Assert.False(config.RunOnStartup); + Assert.Equal("custom.theme", config.ThemeId); + Assert.True(config.UseCtrlKey); + Assert.False(config.UseWinKey); + Assert.False(config.UseAltKey); + Assert.True(config.UseShiftKey); + Assert.Equal(0x43u, config.VirtualKey); + Assert.Equal("C", config.KeyName); + Assert.Equal(50, config.PageSize); + Assert.Equal(200, config.MaxItemsBeforeCleanup); + Assert.Equal(600, config.ScrollLoadThreshold); + Assert.NotNull(config.ColorLabels); + Assert.Equal("Urgent", config.ColorLabels["Red"]); + Assert.Equal(90, config.RetentionDays); + Assert.NotNull(config.LastBackupDateUtc); + Assert.Equal(600, config.DuplicateIgnoreWindowMs); + Assert.Equal(200, config.DelayBeforeFocusMs); + Assert.Equal(300, config.DelayBeforePasteMs); + Assert.Equal(20, config.MaxFocusVerifyAttempts); + Assert.Equal(200, config.ThumbnailWidth); + Assert.Equal(90, config.ThumbnailQualityPng); + Assert.Equal(70, config.ThumbnailQualityJpeg); + Assert.Equal(2_000_000, config.ThumbnailGCThreshold); + Assert.Equal(120, config.ThumbnailUIDecodeHeight); + } + + #endregion + + #region JSON Serialization + + [Fact] + public void JsonSerialization_RoundTrip_PreservesValues() + { + var original = new MyMConfig + { + PreferredLanguage = "es-CL", + RunOnStartup = false, + PageSize = 50, + RetentionDays = 60, + ThumbnailWidth = 200 + }; + + var json = JsonSerializer.Serialize(original, MyMConfigJsonContext.Default.MyMConfig); + var deserialized = JsonSerializer.Deserialize(json, MyMConfigJsonContext.Default.MyMConfig); + + Assert.NotNull(deserialized); + Assert.Equal("es-CL", deserialized.PreferredLanguage); + Assert.False(deserialized.RunOnStartup); + Assert.Equal(50, deserialized.PageSize); + Assert.Equal(60, deserialized.RetentionDays); + Assert.Equal(200, deserialized.ThumbnailWidth); + } + + [Fact] + public void JsonSerialization_DefaultConfig_RoundTrips() + { + var original = new MyMConfig(); + + var json = JsonSerializer.Serialize(original, MyMConfigJsonContext.Default.MyMConfig); + var deserialized = JsonSerializer.Deserialize(json, MyMConfigJsonContext.Default.MyMConfig); + + Assert.NotNull(deserialized); + Assert.Equal(original.PreferredLanguage, deserialized.PreferredLanguage); + Assert.Equal(original.PageSize, deserialized.PageSize); + Assert.Equal(original.RetentionDays, deserialized.RetentionDays); + } + + [Fact] + public void JsonSerialization_WithColorLabels_RoundTrips() + { + var original = new MyMConfig + { + ColorLabels = new Dictionary + { + { "Red", "Urgent" }, + { "Green", "Personal" }, + { "Blue", "Work" } + } + }; + + var json = JsonSerializer.Serialize(original, MyMConfigJsonContext.Default.MyMConfig); + var deserialized = JsonSerializer.Deserialize(json, MyMConfigJsonContext.Default.MyMConfig); + + Assert.NotNull(deserialized?.ColorLabels); + Assert.Equal(3, deserialized.ColorLabels.Count); + Assert.Equal("Urgent", deserialized.ColorLabels["Red"]); + Assert.Equal("Personal", deserialized.ColorLabels["Green"]); + Assert.Equal("Work", deserialized.ColorLabels["Blue"]); + } + + [Fact] + public void JsonDeserialization_MissingProperties_UsesDefaults() + { + var json = "{}"; + var config = JsonSerializer.Deserialize(json, MyMConfigJsonContext.Default.MyMConfig); + + Assert.NotNull(config); + Assert.Equal("auto", config.PreferredLanguage); + Assert.True(config.RunOnStartup); + Assert.Equal(30, config.PageSize); + } + + [Fact] + public void JsonDeserialization_PartialProperties_MergesWithDefaults() + { + var json = """{"PageSize": 100, "RetentionDays": 90}"""; + var config = JsonSerializer.Deserialize(json, MyMConfigJsonContext.Default.MyMConfig); + + Assert.NotNull(config); + Assert.Equal(100, config.PageSize); + Assert.Equal(90, config.RetentionDays); + Assert.Equal("auto", config.PreferredLanguage); + Assert.True(config.RunOnStartup); + } + + #endregion +} diff --git a/Tests/CopyPaste.Core.Tests/SearchHelperAndFileExtensionsTests.cs b/Tests/CopyPaste.Core.Tests/SearchHelperAndFileExtensionsTests.cs new file mode 100644 index 0000000..a4a6d18 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/SearchHelperAndFileExtensionsTests.cs @@ -0,0 +1,233 @@ +using Xunit; + +namespace CopyPaste.Core.Tests; + +public sealed class SearchHelperCombinedTests +{ + #region Null and Empty + + [Fact] + public void NormalizeText_Null_ReturnsNull() + { + var result = SearchHelper.NormalizeText(null!); + + Assert.Null(result); + } + + [Fact] + public void NormalizeText_Empty_ReturnsEmpty() + { + var result = SearchHelper.NormalizeText(string.Empty); + + Assert.Equal(string.Empty, result); + } + + #endregion + + #region ASCII and Plain Text + + [Fact] + public void NormalizeText_PlainAscii_ReturnsUnchanged() + { + var result = SearchHelper.NormalizeText("Hello World 123"); + + Assert.Equal("Hello World 123", result); + } + + [Theory] + [InlineData("1234567890")] + [InlineData("@#$%^&*()_+-=[]{}|;':\",./<>?")] + public void NormalizeText_NumbersAndSpecialChars_ReturnsUnchanged(string input) + { + var result = SearchHelper.NormalizeText(input); + + Assert.Equal(input, result); + } + + #endregion + + #region Accented Characters + + [Theory] + [InlineData("café", "cafe")] + [InlineData("résumé", "resume")] + [InlineData("naïve", "naive")] + [InlineData("über", "uber")] + [InlineData("Ångström", "Angstrom")] + public void NormalizeText_AccentedChars_RemovesDiacritics(string input, string expected) + { + var result = SearchHelper.NormalizeText(input); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("ñ", "n")] + [InlineData("Ñoño", "Nono")] + [InlineData("ü", "u")] + [InlineData("Zürich", "Zurich")] + [InlineData("ö", "o")] + [InlineData("Köln", "Koln")] + public void NormalizeText_SpecialLatinChars_RemovesDiacritics(string input, string expected) + { + var result = SearchHelper.NormalizeText(input); + + Assert.Equal(expected, result); + } + + #endregion + + #region Mixed Content + + [Theory] + [InlineData("Café 123!", "Cafe 123!")] + [InlineData("résumé v2.0", "resume v2.0")] + [InlineData("Año 2024 #1", "Ano 2024 #1")] + public void NormalizeText_MixedAccentedAndNonAccented_NormalizesCorrectly(string input, string expected) + { + var result = SearchHelper.NormalizeText(input); + + Assert.Equal(expected, result); + } + + #endregion + + #region CJK and Non-Latin Scripts + + [Theory] + [InlineData("日本語")] + [InlineData("中文字符")] + [InlineData("한국어")] + public void NormalizeText_CjkCharacters_ReturnsUnchanged(string input) + { + var result = SearchHelper.NormalizeText(input); + + Assert.Equal(input, result); + } + + #endregion +} + +public sealed class FileExtensionsCombinedTests +{ + #region Null and Empty + + [Theory] + [InlineData(null)] + [InlineData("")] + public void GetContentType_NullOrEmpty_ReturnsFile(string? extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.Equal(ClipboardContentType.File, result); + } + + #endregion + + #region Audio Extensions + + [Theory] + [InlineData(".mp3")] + [InlineData(".wav")] + [InlineData(".flac")] + [InlineData(".aac")] + [InlineData(".ogg")] + [InlineData(".wma")] + [InlineData(".m4a")] + public void GetContentType_AudioExtensions_ReturnsAudio(string extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.Equal(ClipboardContentType.Audio, result); + } + + #endregion + + #region Video Extensions + + [Theory] + [InlineData(".mp4")] + [InlineData(".avi")] + [InlineData(".mkv")] + [InlineData(".mov")] + [InlineData(".wmv")] + [InlineData(".flv")] + [InlineData(".webm")] + public void GetContentType_VideoExtensions_ReturnsVideo(string extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.Equal(ClipboardContentType.Video, result); + } + + #endregion + + #region Image Extensions + + [Theory] + [InlineData(".png")] + [InlineData(".jpg")] + [InlineData(".jpeg")] + [InlineData(".gif")] + [InlineData(".bmp")] + [InlineData(".webp")] + [InlineData(".svg")] + [InlineData(".ico")] + public void GetContentType_ImageExtensions_ReturnsImage(string extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.Equal(ClipboardContentType.Image, result); + } + + #endregion + + #region Case Insensitivity + + [Theory] + [InlineData(".Mp3")] + [InlineData(".MP3")] + [InlineData(".PNG")] + [InlineData(".Png")] + [InlineData(".mp4")] + [InlineData(".MP4")] + [InlineData(".Mp4")] + public void GetContentType_CaseInsensitive_ReturnsCorrectType(string extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.NotEqual(ClipboardContentType.File, result); + } + + #endregion + + #region Unknown Extensions + + [Theory] + [InlineData(".txt")] + [InlineData(".pdf")] + [InlineData(".doc")] + public void GetContentType_UnknownExtension_ReturnsFile(string extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.Equal(ClipboardContentType.File, result); + } + + #endregion + + #region No Dot + + [Theory] + [InlineData("mp3")] + [InlineData("png")] + [InlineData("mp4")] + public void GetContentType_NoDot_ReturnsFile(string extension) + { + var result = FileExtensions.GetContentType(extension); + + Assert.Equal(ClipboardContentType.File, result); + } + + #endregion +} diff --git a/Tests/CopyPaste.Core.Tests/SqliteRepositoryTests.cs b/Tests/CopyPaste.Core.Tests/SqliteRepositoryTests.cs index dd52bd1..fc1177f 100644 --- a/Tests/CopyPaste.Core.Tests/SqliteRepositoryTests.cs +++ b/Tests/CopyPaste.Core.Tests/SqliteRepositoryTests.cs @@ -23,35 +23,36 @@ public SqliteRepositoryTests() _repository = new SqliteRepository(_dbPath); } - #region Save Tests - - [Fact] - public void Save_NewItem_StoresInDatabase() - { - var item = new ClipboardItem + private static ClipboardItem CreateItem( + string content = "test", + ClipboardContentType type = ClipboardContentType.Text, + bool isPinned = false, + CardColor color = CardColor.None, + string? label = null, + string? contentHash = null, + DateTime? createdAt = null, + DateTime? modifiedAt = null) + { + return new ClipboardItem { - Content = "Test content", - Type = ClipboardContentType.Text, - AppSource = "TestApp" + Content = content, + Type = type, + IsPinned = isPinned, + CardColor = color, + Label = label, + ContentHash = contentHash, + CreatedAt = createdAt ?? DateTime.UtcNow, + ModifiedAt = modifiedAt ?? DateTime.UtcNow }; - - _repository.Save(item); - - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Equal("Test content", retrieved.Content); - Assert.Equal(ClipboardContentType.Text, retrieved.Type); - Assert.Equal("TestApp", retrieved.AppSource); } + #region Save and GetById + [Fact] - public void Save_WithoutId_GeneratesId() + public void Save_AssignsGuidIfEmpty() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text - }; + var item = CreateItem(); + item.Id = Guid.Empty; _repository.Save(item); @@ -59,416 +60,366 @@ public void Save_WithoutId_GeneratesId() } [Fact] - public void Save_WithMetadata_StoresMetadata() + public void Save_PersistsItem() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text, - Metadata = "{\"key\":\"value\"}" - }; + var item = CreateItem("hello"); _repository.Save(item); var retrieved = _repository.GetById(item.Id); Assert.NotNull(retrieved); - Assert.Equal("{\"key\":\"value\"}", retrieved.Metadata); - } - - [Fact] - public void Save_NullItem_ThrowsException() - { - Assert.Throws(() => _repository.Save(null!)); + Assert.Equal("hello", retrieved.Content); } [Fact] - public void Save_PinnedItem_StoresPinnedFlag() + public void GetById_ExistingItem_ReturnsItem() { - var item = new ClipboardItem - { - Content = "Pinned", - Type = ClipboardContentType.Text, - IsPinned = true - }; - + var item = CreateItem(); _repository.Save(item); var retrieved = _repository.GetById(item.Id); + Assert.NotNull(retrieved); - Assert.True(retrieved.IsPinned); + Assert.Equal(item.Id, retrieved.Id); } [Fact] - public void Save_WithLabel_StoresLabel() + public void GetById_NonExistent_ReturnsNull() { - var item = new ClipboardItem - { - Content = "Some UUID: abc-123", - Type = ClipboardContentType.Text, - Label = "API Key Production" - }; - - _repository.Save(item); + var result = _repository.GetById(Guid.NewGuid()); - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Equal("API Key Production", retrieved.Label); + Assert.Null(result); } [Fact] - public void Save_WithCardColor_StoresColor() + public void Save_AllFieldsPersisted() { + var id = Guid.NewGuid(); + var createdAt = new DateTime(2024, 1, 15, 10, 30, 0, DateTimeKind.Utc); + var modifiedAt = new DateTime(2024, 6, 20, 14, 45, 0, DateTimeKind.Utc); var item = new ClipboardItem { - Content = "Important note", - Type = ClipboardContentType.Text, - CardColor = CardColor.Red + Id = id, + Content = "full content", + Type = ClipboardContentType.Link, + AppSource = "TestApp", + IsPinned = true, + Metadata = "{\"key\":\"value\"}", + Label = "my label", + CardColor = CardColor.Blue, + PasteCount = 7, + ContentHash = "abc123hash", + CreatedAt = createdAt, + ModifiedAt = modifiedAt }; _repository.Save(item); - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Equal(CardColor.Red, retrieved.CardColor); + var r = _repository.GetById(id); + Assert.NotNull(r); + Assert.Equal(id, r.Id); + Assert.Equal("full content", r.Content); + Assert.Equal(ClipboardContentType.Link, r.Type); + Assert.Equal("TestApp", r.AppSource); + Assert.True(r.IsPinned); + Assert.Equal("{\"key\":\"value\"}", r.Metadata); + Assert.Equal("my label", r.Label); + Assert.Equal(CardColor.Blue, r.CardColor); + Assert.Equal(7, r.PasteCount); + Assert.Equal("abc123hash", r.ContentHash); + Assert.Equal(createdAt, r.CreatedAt.ToUniversalTime()); + Assert.Equal(modifiedAt, r.ModifiedAt.ToUniversalTime()); } - [Fact] - public void Save_WithoutLabel_HasNullLabel() - { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text - }; - - _repository.Save(item); + #endregion - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Null(retrieved.Label); - } + #region Update [Fact] - public void Save_WithoutCardColor_HasNoneColor() + public void Update_ModifiesExistingItem() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text - }; - + var item = CreateItem("original"); _repository.Save(item); + item.Content = "updated"; + _repository.Update(item); + var retrieved = _repository.GetById(item.Id); Assert.NotNull(retrieved); - Assert.Equal(CardColor.None, retrieved.CardColor); + Assert.Equal("updated", retrieved.Content); } - #endregion - - #region Update Tests - [Fact] - public void Update_ExistingItem_UpdatesContent() + public void Update_ChangesIsPinned() { - var item = new ClipboardItem - { - Content = "Original", - Type = ClipboardContentType.Text - }; + var item = CreateItem(isPinned: false); _repository.Save(item); - item.Content = "Updated"; + item.IsPinned = true; _repository.Update(item); var retrieved = _repository.GetById(item.Id); Assert.NotNull(retrieved); - Assert.Equal("Updated", retrieved.Content); + Assert.True(retrieved.IsPinned); } - [Fact] - public void Update_ModifiedAt_UpdatesTimestamp() - { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text - }; - _repository.Save(item); + #endregion - var originalModifiedAt = item.ModifiedAt.ToUniversalTime(); - Thread.Sleep(200); // Increased sleep for timestamp resolution + #region GetLatest - item.ModifiedAt = DateTime.UtcNow; - _repository.Update(item); + [Fact] + public void GetLatest_ReturnsNewestByModifiedAt() + { + var older = CreateItem("older", modifiedAt: DateTime.UtcNow.AddSeconds(-10)); + var newer = CreateItem("newer", modifiedAt: DateTime.UtcNow); + _repository.Save(older); + _repository.Save(newer); - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - var retrievedModifiedAt = retrieved.ModifiedAt.ToUniversalTime(); + var latest = _repository.GetLatest(); - // Compare with second precision and 1-second tolerance for clock drift - var secondsDiff = (retrievedModifiedAt - originalModifiedAt).TotalSeconds; - Assert.True(secondsDiff >= -1, - $"Modified: {retrievedModifiedAt:O} should be >= Original: {originalModifiedAt:O} (diff: {secondsDiff:F2}s)"); + Assert.NotNull(latest); + Assert.Equal("newer", latest.Content); } [Fact] - public void Update_NullItem_ThrowsException() + public void GetLatest_EmptyDb_ReturnsNull() { - Assert.Throws(() => _repository.Update(null!)); + var result = _repository.GetLatest(); + + Assert.Null(result); } [Fact] - public void Update_IsPinned_UpdatesPinnedStatus() + public void GetLatest_ExcludesUnknownType() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text, - IsPinned = false - }; - _repository.Save(item); + var textItem = CreateItem("text item"); + var unknownItem = CreateItem("unknown item", type: ClipboardContentType.Unknown, modifiedAt: DateTime.UtcNow.AddSeconds(5)); + _repository.Save(textItem); + _repository.Save(unknownItem); - item.IsPinned = true; - _repository.Update(item); + var latest = _repository.GetLatest(); - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.True(retrieved.IsPinned); + Assert.NotNull(latest); + Assert.Equal("text item", latest.Content); } + #endregion + + #region FindByContentHash + [Fact] - public void Update_Label_UpdatesLabel() + public void FindByContentHash_ExistingHash_ReturnsItem() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text, - Label = null - }; + var item = CreateItem(contentHash: "hash_xyz"); _repository.Save(item); - item.Label = "My Custom Label"; - _repository.Update(item); + var result = _repository.FindByContentHash("hash_xyz"); - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Equal("My Custom Label", retrieved.Label); + Assert.NotNull(result); + Assert.Equal(item.Id, result.Id); } [Fact] - public void Update_CardColor_UpdatesColor() + public void FindByContentHash_NoMatch_ReturnsNull() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text, - CardColor = CardColor.None - }; + var item = CreateItem(contentHash: "hash_abc"); _repository.Save(item); - item.CardColor = CardColor.Blue; - _repository.Update(item); + var result = _repository.FindByContentHash("hash_not_found"); - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Equal(CardColor.Blue, retrieved.CardColor); + Assert.Null(result); } [Fact] - public void Update_LabelToNull_ClearsLabel() + public void FindByContentHash_NullOrEmpty_ReturnsNull() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text, - Label = "Existing Label" - }; + var item = CreateItem(contentHash: "some_hash"); _repository.Save(item); - item.Label = null; - _repository.Update(item); - - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); - Assert.Null(retrieved.Label); + Assert.Null(_repository.FindByContentHash(null!)); + Assert.Null(_repository.FindByContentHash(string.Empty)); } #endregion - #region GetById Tests + #region FindByContentAndType [Fact] - public void GetById_ExistingItem_ReturnsItem() + public void FindByContentAndType_Match_ReturnsItem() { - var item = new ClipboardItem - { - Content = "Test", - Type = ClipboardContentType.Text - }; + var item = CreateItem("find me", ClipboardContentType.Text); _repository.Save(item); - var retrieved = _repository.GetById(item.Id); + var result = _repository.FindByContentAndType("find me", ClipboardContentType.Text); - Assert.NotNull(retrieved); - Assert.Equal(item.Id, retrieved.Id); + Assert.NotNull(result); + Assert.Equal(item.Id, result.Id); } [Fact] - public void GetById_NonExistentItem_ReturnsNull() + public void FindByContentAndType_NoMatch_ReturnsNull() { - var result = _repository.GetById(Guid.NewGuid()); + var item = CreateItem("something", ClipboardContentType.Text); + _repository.Save(item); + + var result = _repository.FindByContentAndType("not found", ClipboardContentType.Text); Assert.Null(result); } [Fact] - public void GetById_EmptyGuid_ReturnsNull() + public void FindByContentAndType_SameContentDifferentType_ReturnsNull() { - var result = _repository.GetById(Guid.Empty); + var item = CreateItem("shared content", ClipboardContentType.Text); + _repository.Save(item); + + var result = _repository.FindByContentAndType("shared content", ClipboardContentType.Link); Assert.Null(result); } #endregion - #region GetLatest Tests + #region GetAll [Fact] - public void GetLatest_WithItems_ReturnsMostRecent() + public void GetAll_ReturnsAllItems() { - var item1 = new ClipboardItem { Content = "First", Type = ClipboardContentType.Text }; - var item2 = new ClipboardItem { Content = "Second", Type = ClipboardContentType.Text }; - - _repository.Save(item1); - Thread.Sleep(50); - _repository.Save(item2); + _repository.Save(CreateItem("a")); + _repository.Save(CreateItem("b")); + _repository.Save(CreateItem("c")); - var latest = _repository.GetLatest(); + var all = _repository.GetAll().ToList(); - Assert.NotNull(latest); - Assert.Equal("Second", latest.Content); + Assert.Equal(3, all.Count); } [Fact] - public void GetLatest_EmptyDatabase_ReturnsNull() + public void GetAll_ExcludesUnknownType() { - var latest = _repository.GetLatest(); + _repository.Save(CreateItem("visible", ClipboardContentType.Text)); + _repository.Save(CreateItem("hidden", ClipboardContentType.Unknown)); - Assert.Null(latest); + var all = _repository.GetAll().ToList(); + + Assert.Single(all); + Assert.Equal("visible", all[0].Content); } [Fact] - public void GetLatest_IgnoresUnknownType() + public void GetAll_OrderedByModifiedAtDesc() { - var unknownItem = new ClipboardItem { Content = "Unknown", Type = ClipboardContentType.Unknown }; - var textItem = new ClipboardItem { Content = "Text", Type = ClipboardContentType.Text }; - - _repository.Save(textItem); - _repository.Save(unknownItem); + var first = CreateItem("first", modifiedAt: DateTime.UtcNow.AddSeconds(-20)); + var second = CreateItem("second", modifiedAt: DateTime.UtcNow.AddSeconds(-10)); + var third = CreateItem("third", modifiedAt: DateTime.UtcNow); + _repository.Save(first); + _repository.Save(second); + _repository.Save(third); - var latest = _repository.GetLatest(); + var all = _repository.GetAll().ToList(); - Assert.NotNull(latest); - Assert.Equal("Text", latest.Content); + Assert.Equal("third", all[0].Content); + Assert.Equal("second", all[1].Content); + Assert.Equal("first", all[2].Content); } #endregion - #region GetAll Tests + #region Delete [Fact] - public void GetAll_ReturnsAllItems() + public void Delete_RemovesItem() { - var item1 = new ClipboardItem { Content = "First", Type = ClipboardContentType.Text }; - var item2 = new ClipboardItem { Content = "Second", Type = ClipboardContentType.Text }; - var item3 = new ClipboardItem { Content = "Third", Type = ClipboardContentType.Text }; - - _repository.Save(item1); - _repository.Save(item2); - _repository.Save(item3); + var item = CreateItem(); + _repository.Save(item); - var all = _repository.GetAll().ToList(); + _repository.Delete(item.Id); - Assert.Equal(3, all.Count); + Assert.Null(_repository.GetById(item.Id)); } [Fact] - public void GetAll_OrdersByModifiedAtDesc() + public void Delete_NonExistent_DoesNotThrow() { - var item1 = new ClipboardItem { Content = "First", Type = ClipboardContentType.Text }; - var item2 = new ClipboardItem { Content = "Second", Type = ClipboardContentType.Text }; + var ex = Record.Exception(() => _repository.Delete(Guid.NewGuid())); - _repository.Save(item1); - Thread.Sleep(50); - _repository.Save(item2); + Assert.Null(ex); + } - var all = _repository.GetAll().ToList(); + #endregion - Assert.Equal("Second", all[0].Content); - Assert.Equal("First", all[1].Content); - } + #region ClearOldItems [Fact] - public void GetAll_EmptyDatabase_ReturnsEmpty() + public void ClearOldItems_DeletesOlderThanDays() { - var all = _repository.GetAll().ToList(); + var oldItem = CreateItem("old", createdAt: DateTime.UtcNow.AddDays(-10)); + var newItem = CreateItem("new"); + _repository.Save(oldItem); + _repository.Save(newItem); + + _repository.ClearOldItems(7); - Assert.Empty(all); + Assert.Null(_repository.GetById(oldItem.Id)); + Assert.NotNull(_repository.GetById(newItem.Id)); } [Fact] - public void GetAll_IgnoresUnknownType() + public void ClearOldItems_ExcludesPinned_WhenTrue() { - var unknownItem = new ClipboardItem { Content = "Unknown", Type = ClipboardContentType.Unknown }; - var textItem = new ClipboardItem { Content = "Text", Type = ClipboardContentType.Text }; + var oldPinned = CreateItem("old pinned", isPinned: true, createdAt: DateTime.UtcNow.AddDays(-10)); + _repository.Save(oldPinned); - _repository.Save(textItem); - _repository.Save(unknownItem); + var deleted = _repository.ClearOldItems(7, excludePinned: true); - var all = _repository.GetAll().ToList(); - - Assert.Single(all); - Assert.Equal("Text", all[0].Content); + Assert.Equal(0, deleted); + Assert.NotNull(_repository.GetById(oldPinned.Id)); } - #endregion - - #region Delete Tests - [Fact] - public void Delete_ExistingItem_RemovesFromDatabase() + public void ClearOldItems_IncludesPinned_WhenFalse() { - var item = new ClipboardItem { Content = "Test", Type = ClipboardContentType.Text }; - _repository.Save(item); + var oldPinned = CreateItem("old pinned", isPinned: true, createdAt: DateTime.UtcNow.AddDays(-10)); + _repository.Save(oldPinned); - _repository.Delete(item.Id); + var deleted = _repository.ClearOldItems(7, excludePinned: false); - var retrieved = _repository.GetById(item.Id); - Assert.Null(retrieved); + Assert.Equal(1, deleted); + Assert.Null(_repository.GetById(oldPinned.Id)); } [Fact] - public void Delete_NonExistentItem_DoesNotThrow() + public void ClearOldItems_ReturnsDeletedCount() { - _repository.Delete(Guid.NewGuid()); + _repository.Save(CreateItem("old1", createdAt: DateTime.UtcNow.AddDays(-10))); + _repository.Save(CreateItem("old2", createdAt: DateTime.UtcNow.AddDays(-15))); + _repository.Save(CreateItem("new")); + + var count = _repository.ClearOldItems(7); - // Should not throw - Assert.True(true); + Assert.Equal(2, count); } #endregion - #region Search Tests + #region Search [Fact] - public void Search_FindsMatchingContent() + public void Search_EmptyQuery_ReturnsAllOrdered() { - var item1 = new ClipboardItem { Content = "Hello World", Type = ClipboardContentType.Text }; - var item2 = new ClipboardItem { Content = "Goodbye World", Type = ClipboardContentType.Text }; + _repository.Save(CreateItem("alpha")); + _repository.Save(CreateItem("beta")); - _repository.Save(item1); - _repository.Save(item2); + var results = _repository.Search(string.Empty).ToList(); + + Assert.Equal(2, results.Count); + } + + [Fact] + public void Search_WithQuery_FindsMatchingContent() + { + _repository.Save(CreateItem("Hello World")); + _repository.Save(CreateItem("Goodbye")); var results = _repository.Search("Hello").ToList(); @@ -477,154 +428,162 @@ public void Search_FindsMatchingContent() } [Fact] - public void Search_EmptyQuery_ReturnsAll() + public void Search_RespectsLimitAndSkip() { - var item1 = new ClipboardItem { Content = "First", Type = ClipboardContentType.Text }; - var item2 = new ClipboardItem { Content = "Second", Type = ClipboardContentType.Text }; - - _repository.Save(item1); - _repository.Save(item2); + for (int i = 0; i < 10; i++) + _repository.Save(CreateItem($"item {i}")); - var results = _repository.Search("").ToList(); + var page1 = _repository.Search(string.Empty, limit: 3, skip: 0).ToList(); + var page2 = _repository.Search(string.Empty, limit: 3, skip: 3).ToList(); - Assert.Equal(2, results.Count); + Assert.Equal(3, page1.Count); + Assert.Equal(3, page2.Count); + Assert.DoesNotContain(page2, i => page1.Any(j => j.Id == i.Id)); } [Fact] - public void Search_WithLimit_ReturnsLimitedResults() + public void Search_NoMatch_ReturnsEmpty() { - for (int i = 0; i < 10; i++) - { - _repository.Save(new ClipboardItem { Content = $"Item {i}", Type = ClipboardContentType.Text }); - } + _repository.Save(CreateItem("some text")); - var results = _repository.Search("", limit: 5).ToList(); + var results = _repository.Search("zzznomatch").ToList(); - Assert.Equal(5, results.Count); + Assert.Empty(results); } + #endregion + + #region SearchAdvanced + [Fact] - public void Search_NoMatches_ReturnsEmpty() + public void SearchAdvanced_NoFilters_ReturnsAll() { - var item = new ClipboardItem { Content = "Test", Type = ClipboardContentType.Text }; - _repository.Save(item); + _repository.Save(CreateItem("first")); + _repository.Save(CreateItem("second")); - var results = _repository.Search("NonExistent").ToList(); + var results = _repository.SearchAdvanced(null, null, null, null, 100, 0).ToList(); - Assert.Empty(results); + Assert.Equal(2, results.Count); } [Fact] - public void Search_FindsByLabel() + public void SearchAdvanced_TypeFilter_FiltersCorrectly() { - var item1 = new ClipboardItem - { - Content = "abc-123-xyz", - Type = ClipboardContentType.Text, - Label = "API Key Production" - }; - var item2 = new ClipboardItem - { - Content = "def-456-uvw", - Type = ClipboardContentType.Text, - Label = "Database Password" - }; - - _repository.Save(item1); - _repository.Save(item2); + _repository.Save(CreateItem("text item", ClipboardContentType.Text)); + _repository.Save(CreateItem("link item", ClipboardContentType.Link)); + _repository.Save(CreateItem("image item", ClipboardContentType.Image)); - var results = _repository.Search("Production").ToList(); + var results = _repository.SearchAdvanced( + null, + new[] { ClipboardContentType.Link }, + null, + null, + 100, + 0).ToList(); Assert.Single(results); - Assert.Equal("API Key Production", results[0].Label); + Assert.Equal("link item", results[0].Content); } - #endregion + [Fact] + public void SearchAdvanced_ColorFilter_FiltersCorrectly() + { + _repository.Save(CreateItem("red item", color: CardColor.Red)); + _repository.Save(CreateItem("blue item", color: CardColor.Blue)); + _repository.Save(CreateItem("no color", color: CardColor.None)); + + var results = _repository.SearchAdvanced( + null, + null, + new[] { CardColor.Red }, + null, + 100, + 0).ToList(); - #region ClearOldItems Tests + Assert.Single(results); + Assert.Equal("red item", results[0].Content); + } [Fact] - public void ClearOldItems_RemovesOldItems() + public void SearchAdvanced_IsPinnedFilter_FiltersCorrectly() { - var oldItem = new ClipboardItem - { - Content = "Old", - Type = ClipboardContentType.Text, - CreatedAt = DateTime.UtcNow.AddDays(-10) - }; - var newItem = new ClipboardItem - { - Content = "New", - Type = ClipboardContentType.Text - }; - - _repository.Save(oldItem); - _repository.Save(newItem); + _repository.Save(CreateItem("pinned", isPinned: true)); + _repository.Save(CreateItem("not pinned", isPinned: false)); - var count = _repository.ClearOldItems(7); + var results = _repository.SearchAdvanced(null, null, null, true, 100, 0).ToList(); - Assert.Equal(1, count); - Assert.Null(_repository.GetById(oldItem.Id)); - Assert.NotNull(_repository.GetById(newItem.Id)); + Assert.Single(results); + Assert.Equal("pinned", results[0].Content); } [Fact] - public void ClearOldItems_ExcludesPinned_KeepsPinnedItems() + public void SearchAdvanced_TextQuery_FindsMatches() { - var oldPinnedItem = new ClipboardItem - { - Content = "Old Pinned", - Type = ClipboardContentType.Text, - CreatedAt = DateTime.UtcNow.AddDays(-10), - IsPinned = true - }; + _repository.Save(CreateItem("unique phrase here")); + _repository.Save(CreateItem("something else")); - _repository.Save(oldPinnedItem); + var results = _repository.SearchAdvanced("unique phrase", null, null, null, 100, 0).ToList(); - var count = _repository.ClearOldItems(7, excludePinned: true); - - Assert.Equal(0, count); - Assert.NotNull(_repository.GetById(oldPinnedItem.Id)); + Assert.Single(results); + Assert.Equal("unique phrase here", results[0].Content); } [Fact] - public void ClearOldItems_IncludePinned_RemovesPinnedItems() + public void SearchAdvanced_CombinedFilters_WorksTogether() { - var oldPinnedItem = new ClipboardItem - { - Content = "Old Pinned", - Type = ClipboardContentType.Text, - CreatedAt = DateTime.UtcNow.AddDays(-10), - IsPinned = true - }; + _repository.Save(CreateItem("text red", ClipboardContentType.Text, color: CardColor.Red)); + _repository.Save(CreateItem("text blue", ClipboardContentType.Text, color: CardColor.Blue)); + _repository.Save(CreateItem("link red", ClipboardContentType.Link, color: CardColor.Red)); - _repository.Save(oldPinnedItem); + var results = _repository.SearchAdvanced( + null, + new[] { ClipboardContentType.Text }, + new[] { CardColor.Red }, + null, + 100, + 0).ToList(); - var count = _repository.ClearOldItems(7, excludePinned: false); - - Assert.Equal(1, count); - Assert.Null(_repository.GetById(oldPinnedItem.Id)); + Assert.Single(results); + Assert.Equal("text red", results[0].Content); } #endregion - #region Database Initialization Tests + #region Dispose [Fact] - public void Constructor_CreatesDatabase() + public void Dispose_CanBeCalledMultipleTimes() { - Assert.True(File.Exists(_dbPath)); + var basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(basePath); + StorageConfig.SetBasePath(basePath); + StorageConfig.Initialize(); + + var dbPath = Path.Combine(basePath, "dispose_test.db"); + + Exception? ex; + using (var repo = new SqliteRepository(dbPath)) + { + ex = Record.Exception(() => + { + repo.Dispose(); + repo.Dispose(); + }); + } + + Assert.Null(ex); + + try { Directory.Delete(basePath, recursive: true); } catch { } } + #endregion + + #region Database resilience + [Fact] - public void Constructor_CreatesTablesAndIndexes() + public void Constructor_CreatesNewDatabase() { - // If we can save and retrieve, tables exist - var item = new ClipboardItem { Content = "Test", Type = ClipboardContentType.Text }; - _repository.Save(item); - - var retrieved = _repository.GetById(item.Id); - Assert.NotNull(retrieved); + Assert.True(File.Exists(_dbPath)); } #endregion @@ -636,9 +595,7 @@ public void Dispose() try { if (Directory.Exists(_basePath)) - { Directory.Delete(_basePath, recursive: true); - } } catch { diff --git a/Tests/CopyPaste.Core.Tests/ThemeContextTests.cs b/Tests/CopyPaste.Core.Tests/ThemeContextTests.cs new file mode 100644 index 0000000..e48c148 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/ThemeContextTests.cs @@ -0,0 +1,97 @@ +using System; +using CopyPaste.Core.Themes; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public class ThemeContextTests +{ + private static readonly StubClipboardService _stubService = new(); + private static readonly MyMConfig _config = new(); + + [Fact] + public void Constructor_SetsServiceProperty() + { + var context = new ThemeContext(_stubService, _config, () => { }, () => { }, () => { }); + + Assert.Same(_stubService, context.Service); + } + + [Fact] + public void Constructor_SetsConfigProperty() + { + var context = new ThemeContext(_stubService, _config, () => { }, () => { }, () => { }); + + Assert.Same(_config, context.Config); + } + + [Fact] + public void Constructor_SetsOpenSettingsAction() + { + var called = false; + var context = new ThemeContext(_stubService, _config, () => called = true, () => { }, () => { }); + + context.OpenSettings(); + + Assert.True(called); + } + + [Fact] + public void Constructor_SetsOpenHelpAction() + { + var called = false; + var context = new ThemeContext(_stubService, _config, () => { }, () => called = true, () => { }); + + context.OpenHelp(); + + Assert.True(called); + } + + [Fact] + public void Constructor_SetsRequestExitAction() + { + var called = false; + var context = new ThemeContext(_stubService, _config, () => { }, () => { }, () => called = true); + + context.RequestExit(); + + Assert.True(called); + } + + [Fact] + public void Constructor_WithCustomConfig_ReflectsValues() + { + var customConfig = new MyMConfig { PageSize = 99, RetentionDays = 7 }; + var context = new ThemeContext(_stubService, customConfig, () => { }, () => { }, () => { }); + + Assert.Equal(99, context.Config.PageSize); + Assert.Equal(7, context.Config.RetentionDays); + } + + private sealed class StubClipboardService : IClipboardService + { + public event Action? OnItemAdded; + public event Action? OnThumbnailReady; + public event Action? OnItemReactivated; + public int PasteIgnoreWindowMs { get; set; } + + public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { } + public void AddImage(byte[]? dibData, string? source) { } + public void AddFiles(System.Collections.ObjectModel.Collection? files, ClipboardContentType type, string? source) { } + public System.Collections.Generic.IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => []; + public System.Collections.Generic.IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, System.Collections.Generic.IReadOnlyCollection? types, System.Collections.Generic.IReadOnlyCollection? colors, bool? isPinned) => []; + public void RemoveItem(Guid id) { } + public void UpdatePin(Guid id, bool isPinned) { } + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } + + // Suppress unused event warnings + internal void SuppressWarnings() + { + OnItemAdded?.Invoke(null!); + OnThumbnailReady?.Invoke(null!); + OnItemReactivated?.Invoke(null!); + } + } +} diff --git a/Tests/CopyPaste.Core.Tests/ThemeInfoTests.cs b/Tests/CopyPaste.Core.Tests/ThemeInfoTests.cs new file mode 100644 index 0000000..0b68744 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/ThemeInfoTests.cs @@ -0,0 +1,92 @@ +using CopyPaste.Core.Themes; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public class ThemeInfoTests +{ + [Fact] + public void Constructor_SetsAllProperties() + { + var info = new ThemeInfo("test.id", "Test Theme", "1.0.0", "Test Author", false); + + Assert.Equal("test.id", info.Id); + Assert.Equal("Test Theme", info.Name); + Assert.Equal("1.0.0", info.Version); + Assert.Equal("Test Author", info.Author); + Assert.False(info.IsCommunity); + } + + [Fact] + public void Constructor_CommunityTheme_SetsIsCommunityTrue() + { + var info = new ThemeInfo("community.theme", "Community", "2.0.0", "Author", true); + + Assert.True(info.IsCommunity); + } + + [Fact] + public void Equality_SameValues_AreEqual() + { + var info1 = new ThemeInfo("test.id", "Test", "1.0.0", "Author", false); + var info2 = new ThemeInfo("test.id", "Test", "1.0.0", "Author", false); + + Assert.Equal(info1, info2); + } + + [Fact] + public void Equality_DifferentId_AreNotEqual() + { + var info1 = new ThemeInfo("test.id1", "Test", "1.0.0", "Author", false); + var info2 = new ThemeInfo("test.id2", "Test", "1.0.0", "Author", false); + + Assert.NotEqual(info1, info2); + } + + [Fact] + public void Equality_DifferentName_AreNotEqual() + { + var info1 = new ThemeInfo("test.id", "Test1", "1.0.0", "Author", false); + var info2 = new ThemeInfo("test.id", "Test2", "1.0.0", "Author", false); + + Assert.NotEqual(info1, info2); + } + + [Fact] + public void Equality_DifferentIsCommunity_AreNotEqual() + { + var info1 = new ThemeInfo("test.id", "Test", "1.0.0", "Author", false); + var info2 = new ThemeInfo("test.id", "Test", "1.0.0", "Author", true); + + Assert.NotEqual(info1, info2); + } + + [Fact] + public void GetHashCode_SameValues_SameHash() + { + var info1 = new ThemeInfo("test.id", "Test", "1.0.0", "Author", false); + var info2 = new ThemeInfo("test.id", "Test", "1.0.0", "Author", false); + + Assert.Equal(info1.GetHashCode(), info2.GetHashCode()); + } + + [Fact] + public void ToString_ContainsId() + { + var info = new ThemeInfo("copypaste.default", "Default", "1.0.0", "CopyPaste", false); + var str = info.ToString(); + + Assert.Contains("copypaste.default", str, System.StringComparison.Ordinal); + } + + [Fact] + public void With_ReturnsModifiedCopy() + { + var original = new ThemeInfo("test.id", "Test", "1.0.0", "Author", false); + var modified = original with { Name = "Modified" }; + + Assert.Equal("Modified", modified.Name); + Assert.Equal("Test", original.Name); + Assert.Equal("test.id", modified.Id); + } +} diff --git a/Tests/CopyPaste.Core.Tests/UpdateCheckerAdditionalTests.cs b/Tests/CopyPaste.Core.Tests/UpdateCheckerAdditionalTests.cs new file mode 100644 index 0000000..a9fb68a --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/UpdateCheckerAdditionalTests.cs @@ -0,0 +1,224 @@ +using System; +using System.IO; +using System.Text.Json; +using Xunit; + +namespace CopyPaste.Core.Tests; + +public sealed class UpdateCheckerAdditionalTests : IDisposable +{ + private readonly string _basePath; + + public UpdateCheckerAdditionalTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(_basePath); + StorageConfig.Initialize(); + } + + #region IsNewerVersion Extended Tests + + [Theory] + [InlineData("2.0.0", "1.0.0", true)] + [InlineData("1.1.0", "1.0.0", true)] + [InlineData("1.0.1", "1.0.0", true)] + [InlineData("1.0.0", "1.0.0", false)] + [InlineData("1.0.0", "2.0.0", false)] + [InlineData("1.0.0", "1.1.0", false)] + [InlineData("1.0.0", "1.0.1", false)] + public void IsNewerVersion_BasicComparisons(string latest, string current, bool expected) + { + Assert.Equal(expected, UpdateChecker.IsNewerVersion(latest, current)); + } + + [Theory] + [InlineData("1.0.0", "1.0.0-beta.1", true)] + [InlineData("1.0.0-beta.1", "1.0.0", false)] + [InlineData("1.0.0-beta.2", "1.0.0-beta.1", true)] + [InlineData("1.0.0-beta.1", "1.0.0-beta.2", false)] + [InlineData("1.0.0-rc.1", "1.0.0-beta.1", true)] + public void IsNewerVersion_PreReleaseComparisons(string latest, string current, bool expected) + { + Assert.Equal(expected, UpdateChecker.IsNewerVersion(latest, current)); + } + + [Theory] + [InlineData("2", "1", true)] + [InlineData("1.1", "1.0", true)] + [InlineData("1.0.0.1", "1.0.0.0", true)] + public void IsNewerVersion_VariousFormats(string latest, string current, bool expected) + { + Assert.Equal(expected, UpdateChecker.IsNewerVersion(latest, current)); + } + + [Theory] + [InlineData("invalid", "1.0.0", false)] + [InlineData("1.0.0", "invalid", false)] + [InlineData("abc", "xyz", false)] + [InlineData("", "", false)] + public void IsNewerVersion_InvalidVersions_ReturnsFalse(string latest, string current, bool expected) + { + Assert.Equal(expected, UpdateChecker.IsNewerVersion(latest, current)); + } + + [Fact] + public void IsNewerVersion_BothPreRelease_SameBase_Compares() + { + Assert.True(UpdateChecker.IsNewerVersion("1.0.0-rc.1", "1.0.0-alpha.1")); + } + + [Fact] + public void IsNewerVersion_SameVersion_ReturnsFalse() + { + Assert.False(UpdateChecker.IsNewerVersion("3.2.1", "3.2.1")); + } + + #endregion + + #region DismissVersion Tests + + [Fact] + public void DismissVersion_DoesNotThrow() + { + var ex = Record.Exception(() => UpdateChecker.DismissVersion("1.0.0")); + Assert.Null(ex); + } + + [Fact] + public void DismissVersion_WritesFile() + { + UpdateChecker.DismissVersion("2.5.0"); + + var filePath = Path.Combine(StorageConfig.ConfigPath, "dismissed_update.txt"); + Assert.True(File.Exists(filePath)); + Assert.Equal("2.5.0", File.ReadAllText(filePath)); + } + + [Fact] + public void DismissVersion_OverwritesPreviousVersion() + { + UpdateChecker.DismissVersion("1.0.0"); + UpdateChecker.DismissVersion("2.0.0"); + + var filePath = Path.Combine(StorageConfig.ConfigPath, "dismissed_update.txt"); + Assert.Equal("2.0.0", File.ReadAllText(filePath)); + } + + #endregion + + #region GetCurrentVersion Tests + + [Fact] + public void GetCurrentVersion_ReturnsNonEmptyString() + { + var version = UpdateChecker.GetCurrentVersion(); + + Assert.NotNull(version); + Assert.NotEmpty(version); + } + + [Fact] + public void GetCurrentVersion_DoesNotContainPlusMetadata() + { + var version = UpdateChecker.GetCurrentVersion(); + + Assert.DoesNotContain("+", version, StringComparison.Ordinal); + } + + #endregion + + #region UpdateAvailableEventArgs Tests + + [Fact] + public void UpdateAvailableEventArgs_SetsProperties() + { + var args = new UpdateAvailableEventArgs("2.0.0", "https://github.com/test/releases"); + + Assert.Equal("2.0.0", args.NewVersion); + Assert.Equal("https://github.com/test/releases", args.DownloadUrl); + } + + [Fact] + public void UpdateAvailableEventArgs_InheritsFromEventArgs() + { + var args = new UpdateAvailableEventArgs("1.0.0", "url"); + + Assert.IsAssignableFrom(args); + } + + #endregion + + #region GitHubRelease Tests + + [Fact] + public void GitHubRelease_DefaultValues() + { + var release = new GitHubRelease(); + + Assert.Null(release.TagName); + Assert.Null(release.HtmlUrl); + Assert.False(release.Prerelease); + } + + [Fact] + public void GitHubRelease_PropertiesCanBeSet() + { + var release = new GitHubRelease + { + TagName = "v2.0.0", + HtmlUrl = "https://github.com/test", + Prerelease = true + }; + + Assert.Equal("v2.0.0", release.TagName); + Assert.Equal("https://github.com/test", release.HtmlUrl); + Assert.True(release.Prerelease); + } + + [Fact] + public void GitHubRelease_JsonDeserialization() + { + var json = """{"tag_name": "v1.5.0", "html_url": "https://github.com/test/releases/v1.5.0", "prerelease": false}"""; + var release = JsonSerializer.Deserialize(json, GitHubReleaseJsonContext.Default.GitHubRelease); + + Assert.NotNull(release); + Assert.Equal("v1.5.0", release.TagName); + Assert.Equal("https://github.com/test/releases/v1.5.0", release.HtmlUrl); + Assert.False(release.Prerelease); + } + + [Fact] + public void GitHubRelease_JsonDeserialization_Prerelease() + { + var json = """{"tag_name": "v2.0.0-beta.1", "prerelease": true}"""; + var release = JsonSerializer.Deserialize(json, GitHubReleaseJsonContext.Default.GitHubRelease); + + Assert.NotNull(release); + Assert.True(release.Prerelease); + } + + #endregion + + #region Constructor and Dispose + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + using var checker = new UpdateChecker(); + checker.Dispose(); + var ex = Record.Exception(() => checker.Dispose()); + Assert.Null(ex); + } + + #endregion + + public void Dispose() + { + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } + } +} diff --git a/Tests/CopyPaste.Listener.Tests/CopyPaste.Listener.Tests.csproj b/Tests/CopyPaste.Listener.Tests/CopyPaste.Listener.Tests.csproj index f40365e..841dad0 100644 --- a/Tests/CopyPaste.Listener.Tests/CopyPaste.Listener.Tests.csproj +++ b/Tests/CopyPaste.Listener.Tests/CopyPaste.Listener.Tests.csproj @@ -1,6 +1,7 @@ net10.0-windows10.0.22621.0 + x64;ARM64 false enable diff --git a/Tests/CopyPaste.Listener.Tests/WindowsClipboardListenerShutdownTests.cs b/Tests/CopyPaste.Listener.Tests/WindowsClipboardListenerShutdownTests.cs new file mode 100644 index 0000000..99f0108 --- /dev/null +++ b/Tests/CopyPaste.Listener.Tests/WindowsClipboardListenerShutdownTests.cs @@ -0,0 +1,105 @@ +using CopyPaste.Core; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Xunit; + +namespace CopyPaste.Listener.Tests; + +public sealed class WindowsClipboardListenerShutdownTests +{ + [Fact] + public void Shutdown_BeforeRun_DoesNotThrow() + { + using var listener = new WindowsClipboardListener(new StubClipboardService()); + + var exception = Record.Exception(() => listener.Shutdown()); + + Assert.Null(exception); + } + + [Fact] + public void Shutdown_BeforeRun_DoesNotRequireWindow() + { + // _hwnd is IntPtr.Zero before Run() — Shutdown should skip PostMessage + using var listener = new WindowsClipboardListener(new StubClipboardService()); + + var exception = Record.Exception(() => listener.Shutdown()); + + Assert.Null(exception); + } + + [Fact] + public void Shutdown_WithDifferentServiceInstances_DoesNotThrow() + { + using var listener1 = new WindowsClipboardListener(new StubClipboardService()); + using var listener2 = new WindowsClipboardListener(new StubClipboardService()); + + var exception = Record.Exception(() => + { + listener1.Shutdown(); + listener2.Shutdown(); + }); + + Assert.Null(exception); + } + + [Fact] + public void Dispose_AfterShutdown_DoesNotThrow() + { +#pragma warning disable CA2000 // Testing Dispose behavior directly + var listener = new WindowsClipboardListener(new StubClipboardService()); +#pragma warning restore CA2000 + listener.Shutdown(); + + var exception = Record.Exception(() => listener.Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void Dispose_WithoutRunOrShutdown_DoesNotThrow() + { +#pragma warning disable CA2000 // Testing Dispose behavior directly + var listener = new WindowsClipboardListener(new StubClipboardService()); +#pragma warning restore CA2000 + + var exception = Record.Exception(() => listener.Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void Dispose_CalledTwiceAfterShutdown_DoesNotThrow() + { +#pragma warning disable CA2000 // Testing Dispose behavior directly + var listener = new WindowsClipboardListener(new StubClipboardService()); +#pragma warning restore CA2000 + listener.Shutdown(); + listener.Dispose(); + + var exception = Record.Exception(() => listener.Dispose()); + + Assert.Null(exception); + } + + private sealed class StubClipboardService : IClipboardService + { +#pragma warning disable CS0067 + public event Action? OnItemAdded; + public event Action? OnThumbnailReady; + public event Action? OnItemReactivated; +#pragma warning restore CS0067 + public int PasteIgnoreWindowMs { get; set; } = 450; + public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { } + public void AddImage(byte[]? dibData, string? source) { } + public void AddFiles(Collection? files, ClipboardContentType type, string? source) { } + public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => []; + public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned) => []; + public void RemoveItem(Guid id) { } + public void UpdatePin(Guid id, bool isPinned) { } + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } + } +} diff --git a/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelAdditionalTests.cs b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelAdditionalTests.cs new file mode 100644 index 0000000..c870188 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelAdditionalTests.cs @@ -0,0 +1,1008 @@ +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using Microsoft.UI.Xaml; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class ClipboardItemViewModelAdditionalTests +{ + private static ClipboardItemViewModel CreateVm(ClipboardItem model, bool showPinIndicator = false, int maxLines = 12, int minLines = 3) + { + return new ClipboardItemViewModel( + model, + _ => { }, + (_, _) => { }, + _ => { }, + _ => { }, + showPinIndicator, + maxLines, + minLines + ); + } + + #region TypeIcon Tests + + [Theory] + [InlineData(ClipboardContentType.Text, "\uE8C4")] + [InlineData(ClipboardContentType.Image, "\uE91B")] + [InlineData(ClipboardContentType.Link, "\uE71B")] + [InlineData(ClipboardContentType.File, "\uE8B7")] + [InlineData(ClipboardContentType.Folder, "\uE8D5")] + [InlineData(ClipboardContentType.Audio, "\uE8D6")] + [InlineData(ClipboardContentType.Video, "\uE714")] + [InlineData(ClipboardContentType.Unknown, "\uE7ba")] + public void TypeIcon_ReturnsCorrectGlyph(ClipboardContentType type, string expectedGlyph) + { + var model = new ClipboardItem { Content = "test", Type = type }; + var vm = CreateVm(model); + + Assert.Equal(expectedGlyph, vm.TypeIcon); + } + + #endregion + + #region CardBorderColor Tests + + [Theory] + [InlineData(CardColor.Red, "#E74C3C")] + [InlineData(CardColor.Green, "#2ECC71")] + [InlineData(CardColor.Purple, "#9B59B6")] + [InlineData(CardColor.Yellow, "#F1C40F")] + [InlineData(CardColor.Blue, "#3498DB")] + [InlineData(CardColor.Orange, "#E67E22")] + [InlineData(CardColor.None, "Transparent")] + public void CardBorderColor_ReturnsCorrectHex(CardColor color, string expected) + { + var model = new ClipboardItem { Content = "test", CardColor = color }; + var vm = CreateVm(model); + + Assert.Equal(expected, vm.CardBorderColor); + } + + #endregion + + #region HasCardColor and CardColor Tests + + [Fact] + public void HasCardColor_WhenNone_ReturnsFalse() + { + var model = new ClipboardItem { Content = "test", CardColor = CardColor.None }; + var vm = CreateVm(model); + + Assert.False(vm.HasCardColor); + } + + [Fact] + public void HasCardColor_WhenSet_ReturnsTrue() + { + var model = new ClipboardItem { Content = "test", CardColor = CardColor.Red }; + var vm = CreateVm(model); + + Assert.True(vm.HasCardColor); + } + + [Fact] + public void CardColorVisibility_WhenNone_IsCollapsed() + { + var model = new ClipboardItem { Content = "test", CardColor = CardColor.None }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.CardColorVisibility); + } + + [Fact] + public void CardColorVisibility_WhenSet_IsVisible() + { + var model = new ClipboardItem { Content = "test", CardColor = CardColor.Blue }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.CardColorVisibility); + } + + #endregion + + #region Label Tests + + [Fact] + public void HasLabel_WhenNull_ReturnsFalse() + { + var model = new ClipboardItem { Content = "test", Label = null }; + var vm = CreateVm(model); + + Assert.False(vm.HasLabel); + } + + [Fact] + public void HasLabel_WhenEmpty_ReturnsFalse() + { + var model = new ClipboardItem { Content = "test", Label = "" }; + var vm = CreateVm(model); + + Assert.False(vm.HasLabel); + } + + [Fact] + public void HasLabel_WhenSet_ReturnsTrue() + { + var model = new ClipboardItem { Content = "test", Label = "Important" }; + var vm = CreateVm(model); + + Assert.True(vm.HasLabel); + } + + [Fact] + public void LabelVisibility_WhenHasLabel_IsVisible() + { + var model = new ClipboardItem { Content = "test", Label = "Work" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.LabelVisibility); + } + + [Fact] + public void LabelVisibility_WhenNoLabel_IsCollapsed() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.LabelVisibility); + } + + [Fact] + public void DefaultHeaderVisibility_WhenHasLabel_IsCollapsed() + { + var model = new ClipboardItem { Content = "test", Label = "Labeled" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.DefaultHeaderVisibility); + } + + [Fact] + public void DefaultHeaderVisibility_WhenNoLabel_IsVisible() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.DefaultHeaderVisibility); + } + + #endregion + + #region PasteCount Tests + + [Fact] + public void PasteCountDisplay_WhenZero_ShowsTimesZero() + { + var model = new ClipboardItem { Content = "test", PasteCount = 0 }; + var vm = CreateVm(model); + + Assert.Equal("×0", vm.PasteCountDisplay); + } + + [Fact] + public void PasteCountDisplay_WhenUnder1000_ShowsExactCount() + { + var model = new ClipboardItem { Content = "test", PasteCount = 42 }; + var vm = CreateVm(model); + + Assert.Equal("×42", vm.PasteCountDisplay); + } + + [Fact] + public void PasteCountDisplay_When999_ShowsExactCount() + { + var model = new ClipboardItem { Content = "test", PasteCount = 999 }; + var vm = CreateVm(model); + + Assert.Equal("×999", vm.PasteCountDisplay); + } + + [Fact] + public void PasteCountDisplay_When1000_ShowsK() + { + var model = new ClipboardItem { Content = "test", PasteCount = 1000 }; + var vm = CreateVm(model); + + Assert.Equal("×1K+", vm.PasteCountDisplay); + } + + [Fact] + public void PasteCountDisplay_WhenOver1000_ShowsK() + { + var model = new ClipboardItem { Content = "test", PasteCount = 5000 }; + var vm = CreateVm(model); + + Assert.Equal("×1K+", vm.PasteCountDisplay); + } + + [Fact] + public void PasteCountVisibility_WhenZero_IsCollapsed() + { + var model = new ClipboardItem { Content = "test", PasteCount = 0 }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.PasteCountVisibility); + } + + [Fact] + public void PasteCountVisibility_WhenPositive_IsVisible() + { + var model = new ClipboardItem { Content = "test", PasteCount = 1 }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.PasteCountVisibility); + } + + #endregion + + #region AppSource Tests + + [Fact] + public void AppSource_WhenNull_ReturnsNull() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.Null(vm.AppSource); + } + + [Fact] + public void AppSource_WhenSet_ReturnsValue() + { + var model = new ClipboardItem { Content = "test", AppSource = "Chrome.exe" }; + var vm = CreateVm(model); + + Assert.Equal("Chrome.exe", vm.AppSource); + } + + [Fact] + public void AppSourceVisibility_WhenNull_IsCollapsed() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.AppSourceVisibility); + } + + [Fact] + public void AppSourceVisibility_WhenSet_IsVisible() + { + var model = new ClipboardItem { Content = "test", AppSource = "App" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.AppSourceVisibility); + } + + #endregion + + #region FileSize from Metadata + + [Fact] + public void FileSize_WithFileSizeMetadata_ReturnsFormattedSize() + { + var model = new ClipboardItem + { + Content = "test.txt", + Type = ClipboardContentType.File, + Metadata = """{"file_size": 1024}""" + }; + var vm = CreateVm(model); + + Assert.Equal("1 KB", vm.FileSize); + } + + [Fact] + public void FileSize_WithSizeMetadata_ReturnsFormattedSize() + { + var model = new ClipboardItem + { + Content = "image.png", + Type = ClipboardContentType.Image, + Metadata = """{"size": 2048}""" + }; + var vm = CreateVm(model); + + Assert.Equal("2 KB", vm.FileSize); + } + + [Fact] + public void FileSize_WithNoMetadata_ReturnsNull() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var vm = CreateVm(model); + + Assert.Null(vm.FileSize); + } + + [Fact] + public void FileSize_WithInvalidJson_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "test", + Metadata = "invalid json" + }; + var vm = CreateVm(model); + + Assert.Null(vm.FileSize); + } + + [Fact] + public void FileSize_LargeBytes_FormatsAsMB() + { + var model = new ClipboardItem + { + Content = "video.mp4", + Type = ClipboardContentType.Video, + Metadata = """{"file_size": 10485760}""" + }; + var vm = CreateVm(model); + + Assert.Equal("10 MB", vm.FileSize); + } + + [Fact] + public void FileSize_SmallBytes_FormatsAsB() + { + var model = new ClipboardItem + { + Content = "small.txt", + Type = ClipboardContentType.File, + Metadata = """{"file_size": 100}""" + }; + var vm = CreateVm(model); + + Assert.Equal("100 B", vm.FileSize); + } + + [Fact] + public void FileSizeVisibility_WhenHasSize_IsVisible() + { + var model = new ClipboardItem + { + Content = "test.txt", + Type = ClipboardContentType.File, + Metadata = """{"file_size": 1024}""" + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.FileSizeVisibility); + } + + [Fact] + public void FileSizeVisibility_WhenNoSize_IsCollapsed() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.FileSizeVisibility); + } + + #endregion + + #region ImageDimensions Tests + + [Fact] + public void ImageDimensions_WithWidthAndHeight_ReturnsFormatted() + { + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image, + Metadata = """{"width": 1920, "height": 1080}""" + }; + var vm = CreateVm(model); + + Assert.Equal("1920×1080", vm.ImageDimensions); + } + + [Fact] + public void ImageDimensions_NonImageType_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "test", + Type = ClipboardContentType.Text, + Metadata = """{"width": 100, "height": 100}""" + }; + var vm = CreateVm(model); + + Assert.Null(vm.ImageDimensions); + } + + [Fact] + public void ImageDimensions_NoMetadata_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image + }; + var vm = CreateVm(model); + + Assert.Null(vm.ImageDimensions); + } + + [Fact] + public void ImageDimensionsVisibility_WhenPresent_IsVisible() + { + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image, + Metadata = """{"width": 800, "height": 600}""" + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.ImageDimensionsVisibility); + } + + #endregion + + #region MediaDuration Tests + + [Fact] + public void MediaDuration_WithDurationMetadata_ReturnsFormatted() + { + var model = new ClipboardItem + { + Content = "video.mp4", + Type = ClipboardContentType.Video, + Metadata = """{"duration": 125}""" + }; + var vm = CreateVm(model); + + Assert.Equal("2:05", vm.MediaDuration); + } + + [Fact] + public void MediaDuration_OverOneHour_ReturnsHourFormat() + { + var model = new ClipboardItem + { + Content = "movie.mp4", + Type = ClipboardContentType.Video, + Metadata = """{"duration": 3665}""" + }; + var vm = CreateVm(model); + + Assert.Equal("1:01:05", vm.MediaDuration); + } + + [Fact] + public void MediaDuration_NoMetadata_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "video.mp4", + Type = ClipboardContentType.Video + }; + var vm = CreateVm(model); + + Assert.Null(vm.MediaDuration); + } + + [Fact] + public void DurationVisibility_WhenHasDuration_IsVisible() + { + var model = new ClipboardItem + { + Content = "audio.mp3", + Type = ClipboardContentType.Audio, + Metadata = """{"duration": 60}""" + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.DurationVisibility); + } + + [Fact] + public void DurationVisibility_WhenNoDuration_IsCollapsed() + { + var model = new ClipboardItem + { + Content = "audio.mp3", + Type = ClipboardContentType.Audio + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.DurationVisibility); + } + + #endregion + + #region Visibility Tests + + [Fact] + public void IsTextVisible_ForTextType_IsVisible() + { + var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.IsTextVisible); + } + + [Fact] + public void IsTextVisible_ForImageType_IsCollapsed() + { + var model = new ClipboardItem { Content = "img.png", Type = ClipboardContentType.Image }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.IsTextVisible); + } + + [Fact] + public void IsTextVisible_ForVideoType_IsCollapsed() + { + var model = new ClipboardItem { Content = "vid.mp4", Type = ClipboardContentType.Video }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.IsTextVisible); + } + + [Fact] + public void IsTextVisible_ForAudioType_IsCollapsed() + { + var model = new ClipboardItem { Content = "audio.mp3", Type = ClipboardContentType.Audio }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.IsTextVisible); + } + + [Fact] + public void IsTextVisible_ForLinkType_IsVisible() + { + var model = new ClipboardItem { Content = "https://example.com", Type = ClipboardContentType.Link }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.IsTextVisible); + } + + [Fact] + public void IsTextVisible_ForFileType_IsVisible() + { + var model = new ClipboardItem { Content = "doc.pdf", Type = ClipboardContentType.File }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.IsTextVisible); + } + + [Fact] + public void MediaThumbnailVisibility_ForVideo_IsVisible() + { + var model = new ClipboardItem { Content = "vid.mp4", Type = ClipboardContentType.Video }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.MediaThumbnailVisibility); + } + + [Fact] + public void MediaThumbnailVisibility_ForAudio_IsVisible() + { + var model = new ClipboardItem { Content = "song.mp3", Type = ClipboardContentType.Audio }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.MediaThumbnailVisibility); + } + + [Fact] + public void MediaThumbnailVisibility_ForText_IsCollapsed() + { + var model = new ClipboardItem { Content = "text", Type = ClipboardContentType.Text }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.MediaThumbnailVisibility); + } + + #endregion + + #region IsExpanded and ContentMaxLines Tests + + [Fact] + public void IsExpanded_InitiallyFalse() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.False(vm.IsExpanded); + } + + [Fact] + public void ContentMaxLines_WhenCollapsed_ReturnsMinLines() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model, minLines: 3, maxLines: 12); + + Assert.Equal(3, vm.ContentMaxLines); + } + + [Fact] + public void ContentMaxLines_WhenExpanded_ReturnsMaxLines() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model, minLines: 3, maxLines: 12); + + vm.IsExpanded = true; + + Assert.Equal(12, vm.ContentMaxLines); + } + + [Fact] + public void ToggleExpanded_TogglesState() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + vm.ToggleExpanded(); + Assert.True(vm.IsExpanded); + + vm.ToggleExpanded(); + Assert.False(vm.IsExpanded); + } + + [Fact] + public void Collapse_SetsExpandedToFalse() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + vm.IsExpanded = true; + vm.Collapse(); + + Assert.False(vm.IsExpanded); + } + + #endregion + + #region Pin Indicator Tests + + [Fact] + public void PinIndicatorVisibility_WhenPinnedAndShowIndicator_IsVisible() + { + var model = new ClipboardItem { Content = "test", IsPinned = true }; + var vm = CreateVm(model, showPinIndicator: true); + + Assert.Equal(Visibility.Visible, vm.PinIndicatorVisibility); + } + + [Fact] + public void PinIndicatorVisibility_WhenPinnedButNoShowIndicator_IsCollapsed() + { + var model = new ClipboardItem { Content = "test", IsPinned = true }; + var vm = CreateVm(model, showPinIndicator: false); + + Assert.Equal(Visibility.Collapsed, vm.PinIndicatorVisibility); + } + + [Fact] + public void PinIndicatorVisibility_WhenNotPinned_IsCollapsed() + { + var model = new ClipboardItem { Content = "test", IsPinned = false }; + var vm = CreateVm(model, showPinIndicator: true); + + Assert.Equal(Visibility.Collapsed, vm.PinIndicatorVisibility); + } + + #endregion + + #region CanEdit Tests + + [Fact] + public void CanEdit_WithEditAction_ReturnsTrue() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.True(vm.CanEdit); + } + + [Fact] + public void CanEdit_WithoutEditAction_ReturnsFalse() + { + var model = new ClipboardItem { Content = "test" }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.False(vm.CanEdit); + } + + #endregion + + #region Content Property Tests + + [Fact] + public void Content_WhenModelContentNull_ReturnsEmpty() + { + var model = new ClipboardItem { Content = null! }; + var vm = CreateVm(model); + + Assert.Equal(string.Empty, vm.Content); + } + + [Fact] + public void Content_ReturnsModelContent() + { + var model = new ClipboardItem { Content = "Hello World" }; + var vm = CreateVm(model); + + Assert.Equal("Hello World", vm.Content); + } + + #endregion + + #region IsFileType Tests + + [Theory] + [InlineData(ClipboardContentType.File, true)] + [InlineData(ClipboardContentType.Folder, true)] + [InlineData(ClipboardContentType.Audio, true)] + [InlineData(ClipboardContentType.Video, true)] + [InlineData(ClipboardContentType.Text, false)] + [InlineData(ClipboardContentType.Image, false)] + [InlineData(ClipboardContentType.Link, false)] + public void IsFileType_MatchesModelIsFileBasedType(ClipboardContentType type, bool expected) + { + var model = new ClipboardItem { Content = "test", Type = type }; + var vm = CreateVm(model); + + Assert.Equal(expected, vm.IsFileType); + } + + #endregion + + #region RefreshLabelAndColor Tests + + [Fact] + public void RefreshLabelAndColor_UpdatesProperties() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + var changedProperties = new System.Collections.Generic.List(); + vm.PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName!); + + vm.RefreshLabelAndColor(); + + Assert.Contains("Label", changedProperties); + Assert.Contains("HasLabel", changedProperties); + Assert.Contains("LabelVisibility", changedProperties); + Assert.Contains("CardColor", changedProperties); + Assert.Contains("HasCardColor", changedProperties); + Assert.Contains("CardBorderColor", changedProperties); + } + + #endregion + + #region RefreshPasteCount Tests + + [Fact] + public void RefreshPasteCount_NotifiesPropertyChanged() + { + var model = new ClipboardItem { Content = "test", PasteCount = 5 }; + var vm = CreateVm(model); + + var changedProperties = new System.Collections.Generic.List(); + vm.PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName!); + + vm.RefreshPasteCount(); + + Assert.Contains("PasteCountDisplay", changedProperties); + Assert.Contains("PasteCountVisibility", changedProperties); + } + + #endregion + + #region RefreshFromModel Tests + + [Fact] + public void RefreshFromModel_UpdatesContent() + { + var model = new ClipboardItem { Content = "original" }; + var vm = CreateVm(model); + + var updated = new ClipboardItem { Content = "updated", Metadata = null }; + vm.RefreshFromModel(updated); + + Assert.Equal("updated", vm.Content); + } + + [Fact] + public void RefreshFromModel_WithNull_Throws() + { + var model = new ClipboardItem { Content = "test" }; + var vm = CreateVm(model); + + Assert.Throws(() => vm.RefreshFromModel(null!)); + } + + [Fact] + public void RefreshFromModel_FiresImagePathChangedEvent() + { + var model = new ClipboardItem { Content = "img.png", Type = ClipboardContentType.Image }; + var vm = CreateVm(model); + + var eventFired = false; + vm.ImagePathChanged += (_, _) => eventFired = true; + + vm.RefreshFromModel(new ClipboardItem { Content = "new.png" }); + + Assert.True(eventFired); + } + + #endregion + + #region RefreshFileStatus Tests + + [Fact] + public void RefreshFileStatus_NotifiesPropertyChanged() + { + var model = new ClipboardItem { Content = "test.txt", Type = ClipboardContentType.File }; + var vm = CreateVm(model); + + var changedProperties = new System.Collections.Generic.List(); + vm.PropertyChanged += (_, e) => changedProperties.Add(e.PropertyName!); + + vm.RefreshFileStatus(); + + Assert.Contains("IsFileAvailable", changedProperties); + Assert.Contains("FileWarningVisibility", changedProperties); + } + + #endregion + + #region PinIconGlyph and PinMenuText + + [Fact] + public void PinIconGlyph_WhenPinned_ReturnsFilled() + { + var model = new ClipboardItem { Content = "test", IsPinned = true }; + var vm = CreateVm(model); + + Assert.Equal("\uE840", vm.PinIconGlyph); + } + + [Fact] + public void PinIconGlyph_WhenNotPinned_ReturnsOutline() + { + var model = new ClipboardItem { Content = "test", IsPinned = false }; + var vm = CreateVm(model); + + Assert.Equal("\uE718", vm.PinIconGlyph); + } + + #endregion + + #region Command Tests + + [Fact] + public void DeleteCommand_InvokesDeleteAction() + { + var deleteCalled = false; + var model = new ClipboardItem { Content = "test" }; + var vm = new ClipboardItemViewModel( + model, + _ => deleteCalled = true, + (_, _) => { }, + _ => { } + ); + + vm.DeleteCommand.Execute(null); + + Assert.True(deleteCalled); + } + + [Fact] + public void PasteCommand_InvokesPasteAction_WithFalsePlain() + { + bool? plainArg = null; + var model = new ClipboardItem { Content = "test" }; + var vm = new ClipboardItemViewModel( + model, + _ => { }, + (_, plain) => plainArg = plain, + _ => { } + ); + + vm.PasteCommand.Execute(null); + + Assert.False(plainArg); + } + + [Fact] + public void PastePlainCommand_InvokesPasteAction_WithTruePlain() + { + bool? plainArg = null; + var model = new ClipboardItem { Content = "test" }; + var vm = new ClipboardItemViewModel( + model, + _ => { }, + (_, plain) => plainArg = plain, + _ => { } + ); + + vm.PastePlainCommand.Execute(null); + + Assert.True(plainArg); + } + + [Fact] + public void TogglePinCommand_TogglesPinState() + { + var model = new ClipboardItem { Content = "test", IsPinned = false }; + var vm = new ClipboardItemViewModel( + model, + _ => { }, + (_, _) => { }, + _ => { } + ); + + vm.TogglePinCommand.Execute(null); + + Assert.True(vm.IsPinned); + } + + [Fact] + public void EditCommand_InvokesEditAction() + { + var editCalled = false; + var model = new ClipboardItem { Content = "test" }; + var vm = new ClipboardItemViewModel( + model, + _ => { }, + (_, _) => { }, + _ => { }, + _ => editCalled = true + ); + + vm.EditCommand.Execute(null); + + Assert.True(editCalled); + } + + #endregion + + #region MediaInfoVisibility Tests + + [Fact] + public void MediaInfoVisibility_ForVideoWithDuration_IsVisible() + { + var model = new ClipboardItem + { + Content = "vid.mp4", + Type = ClipboardContentType.Video, + Metadata = """{"duration": 60}""" + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.MediaInfoVisibility); + } + + [Fact] + public void MediaInfoVisibility_ForTextType_IsCollapsed() + { + var model = new ClipboardItem + { + Content = "hello", + Type = ClipboardContentType.Text + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Collapsed, vm.MediaInfoVisibility); + } + + [Fact] + public void MediaInfoVisibility_ForImageWithDimensions_IsVisible() + { + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image, + Metadata = """{"width": 100, "height": 100}""" + }; + var vm = CreateVm(model); + + Assert.Equal(Visibility.Visible, vm.MediaInfoVisibility); + } + + #endregion +} diff --git a/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelLifecycleTests.cs b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelLifecycleTests.cs new file mode 100644 index 0000000..7626c00 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelLifecycleTests.cs @@ -0,0 +1,261 @@ +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using System; +using System.Collections.Generic; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class ClipboardItemViewModelLifecycleTests +{ + private static ClipboardItemViewModel CreateViewModel( + ClipboardItem? model = null, + int cardMaxLines = 12, + int cardMinLines = 3) + { + model ??= new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + return new ClipboardItemViewModel( + model, + _ => { }, + (_, _) => { }, + _ => { }, + cardMaxLines: cardMaxLines, + cardMinLines: cardMinLines); + } + + #region RefreshPasteCount + + [Fact] + public void RefreshPasteCount_FiresPasteCountDisplayPropertyChanged() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text, PasteCount = 5 }; + var vm = CreateViewModel(model); + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshPasteCount(); + + Assert.Contains(nameof(ClipboardItemViewModel.PasteCountDisplay), changedProps); + } + + [Fact] + public void RefreshPasteCount_FiresPasteCountVisibilityPropertyChanged() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text, PasteCount = 3 }; + var vm = CreateViewModel(model); + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshPasteCount(); + + Assert.Contains(nameof(ClipboardItemViewModel.PasteCountVisibility), changedProps); + } + + [Fact] + public void RefreshPasteCount_AfterModelUpdate_ReflectsNewCount() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text, PasteCount = 0 }; + var vm = CreateViewModel(model); + Assert.Equal("×0", vm.PasteCountDisplay); + + model.PasteCount = 42; + vm.RefreshPasteCount(); + + Assert.Equal("×42", vm.PasteCountDisplay); + } + + [Fact] + public void RefreshPasteCount_WhenCountExceeds999_DisplaysK() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text, PasteCount = 0 }; + var vm = CreateViewModel(model); + + model.PasteCount = 1000; + vm.RefreshPasteCount(); + + Assert.Equal("×1K+", vm.PasteCountDisplay); + } + + #endregion + + #region RefreshFileStatus + + [Fact] + public void RefreshFileStatus_FiresIsFileAvailablePropertyChanged() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var vm = CreateViewModel(model); + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshFileStatus(); + + Assert.Contains(nameof(ClipboardItemViewModel.IsFileAvailable), changedProps); + } + + [Fact] + public void RefreshFileStatus_FiresFileWarningVisibilityPropertyChanged() + { + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var vm = CreateViewModel(model); + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshFileStatus(); + + Assert.Contains(nameof(ClipboardItemViewModel.FileWarningVisibility), changedProps); + } + + [Fact] + public void RefreshFileStatus_DoesNotThrow() + { + var vm = CreateViewModel(); + + var exception = Record.Exception(() => vm.RefreshFileStatus()); + + Assert.Null(exception); + } + + #endregion + + #region ToggleExpanded + + [Fact] + public void ToggleExpanded_SetsIsExpandedToTrue_WhenStartingFalse() + { + var vm = CreateViewModel(); + Assert.False(vm.IsExpanded); + + vm.ToggleExpanded(); + + Assert.True(vm.IsExpanded); + } + + [Fact] + public void ToggleExpanded_SetsIsExpandedToFalse_WhenStartingTrue() + { + var vm = CreateViewModel(); + vm.ToggleExpanded(); // true + + vm.ToggleExpanded(); // back to false + + Assert.False(vm.IsExpanded); + } + + [Fact] + public void ToggleExpanded_FiresContentMaxLinesPropertyChanged() + { + var vm = CreateViewModel(); + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.ToggleExpanded(); + + Assert.Contains(nameof(ClipboardItemViewModel.ContentMaxLines), changedProps); + } + + [Fact] + public void ToggleExpanded_FiresContentLineHeightPropertyChanged() + { + var vm = CreateViewModel(); + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.ToggleExpanded(); + + Assert.Contains(nameof(ClipboardItemViewModel.ContentLineHeight), changedProps); + } + + #endregion + + #region Collapse + + [Fact] + public void Collapse_SetsIsExpandedToFalse_AfterExpand() + { + var vm = CreateViewModel(); + vm.ToggleExpanded(); // expand first + Assert.True(vm.IsExpanded); + + vm.Collapse(); + + Assert.False(vm.IsExpanded); + } + + [Fact] + public void Collapse_WhenAlreadyCollapsed_DoesNotThrow() + { + var vm = CreateViewModel(); + Assert.False(vm.IsExpanded); + + var exception = Record.Exception(() => vm.Collapse()); + + Assert.Null(exception); + Assert.False(vm.IsExpanded); + } + + [Fact] + public void Collapse_CalledTwice_DoesNotThrow() + { + var vm = CreateViewModel(); + vm.ToggleExpanded(); + + var exception = Record.Exception(() => + { + vm.Collapse(); + vm.Collapse(); + }); + + Assert.Null(exception); + Assert.False(vm.IsExpanded); + } + + #endregion + + #region ContentMaxLines + + [Fact] + public void ContentMaxLines_WhenCollapsed_ReturnsCardMinLines() + { + var vm = CreateViewModel(cardMaxLines: 12, cardMinLines: 3); + + Assert.Equal(3, vm.ContentMaxLines); + Assert.False(vm.IsExpanded); + } + + [Fact] + public void ContentMaxLines_WhenExpanded_ReturnsCardMaxLines() + { + var vm = CreateViewModel(cardMaxLines: 12, cardMinLines: 3); + vm.ToggleExpanded(); + + Assert.Equal(12, vm.ContentMaxLines); + } + + [Fact] + public void ContentMaxLines_TogglesCorrectlyBetweenExpandAndCollapse() + { + var vm = CreateViewModel(cardMaxLines: 10, cardMinLines: 4); + + Assert.Equal(4, vm.ContentMaxLines); + + vm.ToggleExpanded(); + Assert.Equal(10, vm.ContentMaxLines); + + vm.Collapse(); + Assert.Equal(4, vm.ContentMaxLines); + } + + [Fact] + public void ContentMaxLines_WithCustomLines_RespectsSettings() + { + var vm = CreateViewModel(cardMaxLines: 20, cardMinLines: 2); + + Assert.Equal(2, vm.ContentMaxLines); + + vm.ToggleExpanded(); + Assert.Equal(20, vm.ContentMaxLines); + } + + #endregion +} diff --git a/Tests/CopyPaste.UI.Tests/ClipboardWindowHelpersTests.cs b/Tests/CopyPaste.UI.Tests/ClipboardWindowHelpersTests.cs new file mode 100644 index 0000000..74b4d0d --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ClipboardWindowHelpersTests.cs @@ -0,0 +1,98 @@ +using CopyPaste.UI.Helpers; +using Windows.UI; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class ClipboardWindowHelpersTests +{ + #region ParseColor Tests + + [Theory] + [InlineData("#E74C3C", 231, 76, 60)] + [InlineData("#2ECC71", 46, 204, 113)] + [InlineData("#9B59B6", 155, 89, 182)] + [InlineData("#F1C40F", 241, 196, 15)] + [InlineData("#3498DB", 52, 152, 219)] + [InlineData("#E67E22", 230, 126, 34)] + public void ParseColor_CardColorHexValues_ReturnsCorrectRgb(string hex, byte r, byte g, byte b) + { + var color = ClipboardWindowHelpers.ParseColor(hex); + + Assert.Equal(r, color.R); + Assert.Equal(g, color.G); + Assert.Equal(b, color.B); + } + + [Theory] + [InlineData("#E74C3C")] + [InlineData("#2ECC71")] + [InlineData("#000000")] + public void ParseColor_AlwaysSetsFullAlpha(string hex) + { + var color = ClipboardWindowHelpers.ParseColor(hex); + + Assert.Equal(255, color.A); + } + + [Theory] + [InlineData("E74C3C", 231, 76, 60)] + [InlineData("000000", 0, 0, 0)] + [InlineData("FFFFFF", 255, 255, 255)] + public void ParseColor_WithoutHashPrefix_ParsesCorrectly(string hex, byte r, byte g, byte b) + { + var color = ClipboardWindowHelpers.ParseColor(hex); + + Assert.Equal(r, color.R); + Assert.Equal(g, color.G); + Assert.Equal(b, color.B); + } + + [Fact] + public void ParseColor_Black_ReturnsZeroRgb() + { + var color = ClipboardWindowHelpers.ParseColor("#000000"); + + Assert.Equal(0, color.R); + Assert.Equal(0, color.G); + Assert.Equal(0, color.B); + Assert.Equal(255, color.A); + } + + [Fact] + public void ParseColor_White_ReturnsMaxRgb() + { + var color = ClipboardWindowHelpers.ParseColor("#FFFFFF"); + + Assert.Equal(255, color.R); + Assert.Equal(255, color.G); + Assert.Equal(255, color.B); + Assert.Equal(255, color.A); + } + + [Fact] + public void ParseColor_HexIsCaseInsensitive() + { + var lower = ClipboardWindowHelpers.ParseColor("#e74c3c"); + var upper = ClipboardWindowHelpers.ParseColor("#E74C3C"); + + Assert.Equal(lower.R, upper.R); + Assert.Equal(lower.G, upper.G); + Assert.Equal(lower.B, upper.B); + } + + [Theory] + [InlineData("#FF0000", 255, 0, 0)] + [InlineData("#00FF00", 0, 255, 0)] + [InlineData("#0000FF", 0, 0, 255)] + public void ParseColor_PrimaryColors_ReturnsExpected(string hex, byte r, byte g, byte b) + { + var color = ClipboardWindowHelpers.ParseColor(hex); + + Assert.Equal(r, color.R); + Assert.Equal(g, color.G); + Assert.Equal(b, color.B); + } + + #endregion +} diff --git a/Tests/CopyPaste.UI.Tests/CompactSettingsTests.cs b/Tests/CopyPaste.UI.Tests/CompactSettingsTests.cs new file mode 100644 index 0000000..207d0cc --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/CompactSettingsTests.cs @@ -0,0 +1,159 @@ +using CopyPaste.UI.Themes; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class CompactSettingsTests +{ + #region Default Values + + [Fact] + public void DefaultSettings_PopupWidth_Is368() + { + var settings = new CompactSettings(); + Assert.Equal(368, settings.PopupWidth); + } + + [Fact] + public void DefaultSettings_PopupHeight_Is480() + { + var settings = new CompactSettings(); + Assert.Equal(480, settings.PopupHeight); + } + + [Fact] + public void DefaultSettings_CardMinLines_Is2() + { + var settings = new CompactSettings(); + Assert.Equal(2, settings.CardMinLines); + } + + [Fact] + public void DefaultSettings_CardMaxLines_Is5() + { + var settings = new CompactSettings(); + Assert.Equal(5, settings.CardMaxLines); + } + + [Fact] + public void DefaultSettings_PinWindow_IsFalse() + { + var settings = new CompactSettings(); + Assert.False(settings.PinWindow); + } + + [Fact] + public void DefaultSettings_ScrollToTopOnPaste_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.ScrollToTopOnPaste); + } + + [Fact] + public void DefaultSettings_HideOnDeactivate_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.HideOnDeactivate); + } + + [Fact] + public void DefaultSettings_ResetScrollOnShow_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.ResetScrollOnShow); + } + + [Fact] + public void DefaultSettings_ResetSearchOnShow_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.ResetSearchOnShow); + } + + [Fact] + public void DefaultSettings_ResetFilterModeOnShow_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.ResetFilterModeOnShow); + } + + [Fact] + public void DefaultSettings_ResetCategoryFilterOnShow_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.ResetCategoryFilterOnShow); + } + + [Fact] + public void DefaultSettings_ResetTypeFilterOnShow_IsTrue() + { + var settings = new CompactSettings(); + Assert.True(settings.ResetTypeFilterOnShow); + } + + #endregion + + #region Property Setters + + [Fact] + public void AllProperties_CanBeModified() + { + var settings = new CompactSettings + { + PopupWidth = 500, + PopupHeight = 600, + CardMinLines = 1, + CardMaxLines = 10, + PinWindow = true, + ScrollToTopOnPaste = false, + HideOnDeactivate = false, + ResetScrollOnShow = false, + ResetSearchOnShow = false, + ResetFilterModeOnShow = false, + ResetCategoryFilterOnShow = false, + ResetTypeFilterOnShow = false + }; + + Assert.Equal(500, settings.PopupWidth); + Assert.Equal(600, settings.PopupHeight); + Assert.Equal(1, settings.CardMinLines); + Assert.Equal(10, settings.CardMaxLines); + Assert.True(settings.PinWindow); + Assert.False(settings.ScrollToTopOnPaste); + Assert.False(settings.HideOnDeactivate); + Assert.False(settings.ResetScrollOnShow); + Assert.False(settings.ResetSearchOnShow); + Assert.False(settings.ResetFilterModeOnShow); + Assert.False(settings.ResetCategoryFilterOnShow); + Assert.False(settings.ResetTypeFilterOnShow); + } + + [Fact] + public void Properties_IndividualSetters_Work() + { + var settings = new CompactSettings(); + + settings.PopupWidth = 500; + Assert.Equal(500, settings.PopupWidth); + + settings.PopupHeight = 600; + Assert.Equal(600, settings.PopupHeight); + + settings.CardMinLines = 1; + Assert.Equal(1, settings.CardMinLines); + + settings.CardMaxLines = 8; + Assert.Equal(8, settings.CardMaxLines); + + settings.PinWindow = true; + Assert.True(settings.PinWindow); + + settings.HideOnDeactivate = false; + Assert.False(settings.HideOnDeactivate); + + settings.ResetSearchOnShow = false; + Assert.False(settings.ResetSearchOnShow); + } + + #endregion +} diff --git a/Tests/CopyPaste.UI.Tests/CopyPaste.UI.Tests.csproj b/Tests/CopyPaste.UI.Tests/CopyPaste.UI.Tests.csproj index 4158594..203b29f 100644 --- a/Tests/CopyPaste.UI.Tests/CopyPaste.UI.Tests.csproj +++ b/Tests/CopyPaste.UI.Tests/CopyPaste.UI.Tests.csproj @@ -2,6 +2,7 @@ net10.0-windows10.0.22621.0 + x64;ARM64 enable enable false @@ -18,6 +19,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/Tests/CopyPaste.UI.Tests/DefaultThemeSettingsTests.cs b/Tests/CopyPaste.UI.Tests/DefaultThemeSettingsTests.cs new file mode 100644 index 0000000..7867340 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/DefaultThemeSettingsTests.cs @@ -0,0 +1,174 @@ +using CopyPaste.UI.Themes; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class DefaultThemeSettingsTests +{ + #region Default Values + + [Fact] + public void DefaultSettings_WindowWidth_Is400() + { + var settings = new DefaultThemeSettings(); + Assert.Equal(400, settings.WindowWidth); + } + + [Fact] + public void DefaultSettings_WindowMarginTop_Is8() + { + var settings = new DefaultThemeSettings(); + Assert.Equal(8, settings.WindowMarginTop); + } + + [Fact] + public void DefaultSettings_WindowMarginBottom_Is16() + { + var settings = new DefaultThemeSettings(); + Assert.Equal(16, settings.WindowMarginBottom); + } + + [Fact] + public void DefaultSettings_CardMinLines_Is3() + { + var settings = new DefaultThemeSettings(); + Assert.Equal(3, settings.CardMinLines); + } + + [Fact] + public void DefaultSettings_CardMaxLines_Is9() + { + var settings = new DefaultThemeSettings(); + Assert.Equal(9, settings.CardMaxLines); + } + + [Fact] + public void DefaultSettings_PinWindow_IsFalse() + { + var settings = new DefaultThemeSettings(); + Assert.False(settings.PinWindow); + } + + [Fact] + public void DefaultSettings_ScrollToTopOnPaste_IsTrue() + { + var settings = new DefaultThemeSettings(); + Assert.True(settings.ScrollToTopOnPaste); + } + + [Fact] + public void DefaultSettings_ResetScrollOnShow_IsTrue() + { + var settings = new DefaultThemeSettings(); + Assert.True(settings.ResetScrollOnShow); + } + + [Fact] + public void DefaultSettings_ResetFilterModeOnShow_IsTrue() + { + var settings = new DefaultThemeSettings(); + Assert.True(settings.ResetFilterModeOnShow); + } + + [Fact] + public void DefaultSettings_ResetContentFilterOnShow_IsTrue() + { + var settings = new DefaultThemeSettings(); + Assert.True(settings.ResetContentFilterOnShow); + } + + [Fact] + public void DefaultSettings_ResetCategoryFilterOnShow_IsTrue() + { + var settings = new DefaultThemeSettings(); + Assert.True(settings.ResetCategoryFilterOnShow); + } + + [Fact] + public void DefaultSettings_ResetTypeFilterOnShow_IsTrue() + { + var settings = new DefaultThemeSettings(); + Assert.True(settings.ResetTypeFilterOnShow); + } + + #endregion + + #region Property Setters + + [Fact] + public void AllProperties_CanBeModified() + { + var settings = new DefaultThemeSettings + { + WindowWidth = 500, + WindowMarginTop = 10, + WindowMarginBottom = 20, + CardMinLines = 2, + CardMaxLines = 12, + PinWindow = true, + ScrollToTopOnPaste = false, + ResetScrollOnShow = false, + ResetFilterModeOnShow = false, + ResetContentFilterOnShow = false, + ResetCategoryFilterOnShow = false, + ResetTypeFilterOnShow = false + }; + + Assert.Equal(500, settings.WindowWidth); + Assert.Equal(10, settings.WindowMarginTop); + Assert.Equal(20, settings.WindowMarginBottom); + Assert.Equal(2, settings.CardMinLines); + Assert.Equal(12, settings.CardMaxLines); + Assert.True(settings.PinWindow); + Assert.False(settings.ScrollToTopOnPaste); + Assert.False(settings.ResetScrollOnShow); + Assert.False(settings.ResetFilterModeOnShow); + Assert.False(settings.ResetContentFilterOnShow); + Assert.False(settings.ResetCategoryFilterOnShow); + Assert.False(settings.ResetTypeFilterOnShow); + } + + [Fact] + public void Properties_IndividualSetters_Work() + { + var settings = new DefaultThemeSettings(); + + settings.WindowWidth = 800; + Assert.Equal(800, settings.WindowWidth); + + settings.WindowMarginTop = 15; + Assert.Equal(15, settings.WindowMarginTop); + + settings.WindowMarginBottom = 25; + Assert.Equal(25, settings.WindowMarginBottom); + + settings.CardMinLines = 1; + Assert.Equal(1, settings.CardMinLines); + + settings.CardMaxLines = 20; + Assert.Equal(20, settings.CardMaxLines); + + settings.PinWindow = true; + Assert.True(settings.PinWindow); + + settings.ScrollToTopOnPaste = false; + Assert.False(settings.ScrollToTopOnPaste); + + settings.ResetScrollOnShow = false; + Assert.False(settings.ResetScrollOnShow); + + settings.ResetFilterModeOnShow = false; + Assert.False(settings.ResetFilterModeOnShow); + + settings.ResetContentFilterOnShow = false; + Assert.False(settings.ResetContentFilterOnShow); + + settings.ResetCategoryFilterOnShow = false; + Assert.False(settings.ResetCategoryFilterOnShow); + + settings.ResetTypeFilterOnShow = false; + Assert.False(settings.ResetTypeFilterOnShow); + } + + #endregion +} diff --git a/Tests/CopyPaste.UI.Tests/LFacadeTests.cs b/Tests/CopyPaste.UI.Tests/LFacadeTests.cs new file mode 100644 index 0000000..cc27972 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/LFacadeTests.cs @@ -0,0 +1,141 @@ +using CopyPaste.UI.Localization; +using System.Diagnostics.CodeAnalysis; +using Xunit; + +namespace CopyPaste.UI.Tests; + +[Collection("L Facade Tests")] +[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", + Justification = "L.Initialize takes ownership; L.Dispose() called in Dispose()")] +public sealed class LFacadeTests : IDisposable +{ + public void Dispose() + { + L.Dispose(); + } + + #region Initialize Tests + + [Fact] + public void Initialize_WithNull_ThrowsArgumentNullException() + { + Assert.Throws(() => L.Initialize(null!)); + } + + [Fact] + public void Initialize_WithValidService_Succeeds() + { + var service = new LocalizationService("en-US"); + + var ex = Record.Exception(() => L.Initialize(service)); + + Assert.Null(ex); + } + + #endregion + + #region Get Before Initialization Tests + + [Fact] + public void Get_BeforeInitialize_ReturnsBracketedKey() + { + var value = L.Get("search_placeholder"); + + Assert.Equal("[search_placeholder]", value); + } + + [Fact] + public void CurrentLanguage_BeforeInitialize_ReturnsEnUS() + { + Assert.Equal("en-US", L.CurrentLanguage); + } + + #endregion + + #region Get After Initialization Tests + + [Fact] + public void Get_AfterInitialize_ReturnsValue() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + + var value = L.Get("window.title"); + + Assert.NotNull(value); + Assert.NotEmpty(value); + Assert.DoesNotContain("[", value, StringComparison.Ordinal); + } + + [Fact] + public void Get_UnknownKey_ReturnsBracketedKey() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + + var value = L.Get("unknown.key.that.does.not.exist"); + + Assert.Equal("[unknown.key.that.does.not.exist]", value); + } + + [Fact] + public void Get_UnknownKey_WithDefault_ReturnsDefault() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + + var value = L.Get("unknown.key.that.does.not.exist", "fallback value"); + + Assert.Equal("fallback value", value); + } + + [Fact] + public void CurrentLanguage_AfterInitialize_ReturnsLanguage() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + + Assert.Equal("en-US", L.CurrentLanguage); + } + + [Fact] + public void Get_WhenServiceExternallyDisposed_ReturnsBracketedKey() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + service.Dispose(); + + var value = L.Get("window.title"); + + Assert.Equal("[window.title]", value); + } + + [Fact] + public void Get_WhenServiceExternallyDisposed_WithDefault_ReturnsDefault() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + service.Dispose(); + + var value = L.Get("window.title", "fallback"); + + Assert.Equal("fallback", value); + } + + #endregion + + #region Dispose Tests + + [Fact] + public void Dispose_DoesNotThrow() + { + var service = new LocalizationService("en-US"); + L.Initialize(service); + + var ex = Record.Exception(() => L.Dispose()); + + Assert.Null(ex); + } + + #endregion +} diff --git a/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs b/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs new file mode 100644 index 0000000..2282591 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs @@ -0,0 +1,179 @@ +using CopyPaste.UI.Localization; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class LocalizationServiceTests : IDisposable +{ + private LocalizationService? _service; + + #region Constructor Tests + + [Fact] + public void Constructor_WithAutoPreference_ResolvesLanguage() + { + _service = new LocalizationService("auto"); + + Assert.NotNull(_service.CurrentLanguage); + Assert.NotEmpty(_service.CurrentLanguage); + } + + [Fact] + public void Constructor_WithNull_ResolvesLanguage() + { + _service = new LocalizationService(null); + + Assert.NotNull(_service.CurrentLanguage); + } + + [Fact] + public void Constructor_WithEnUS_SetsEnglish() + { + _service = new LocalizationService("en-US"); + + Assert.Equal("en-US", _service.CurrentLanguage); + } + + [Fact] + public void Constructor_WithEsCL_SetsSpanish() + { + _service = new LocalizationService("es-CL"); + + Assert.Equal("es-CL", _service.CurrentLanguage); + } + + [Fact] + public void Constructor_WithUnsupportedLanguage_FallsBack() + { + _service = new LocalizationService("zh-CN"); + + Assert.NotNull(_service.CurrentLanguage); + } + + #endregion + + #region Get Tests + + [Fact] + public void Get_ExistingKey_ReturnsValue() + { + _service = new LocalizationService("en-US"); + + var value = _service.Get("clipboard.contextMenu.paste"); + + Assert.NotNull(value); + Assert.NotEmpty(value); + Assert.DoesNotContain("[", value, StringComparison.Ordinal); + } + + [Fact] + public void Get_NonExistingKey_ReturnsBracketedKey() + { + _service = new LocalizationService("en-US"); + + var value = _service.Get("non.existing.key"); + + Assert.Equal("[non.existing.key]", value); + } + + [Fact] + public void Get_NonExistingKey_WithDefault_ReturnsDefault() + { + _service = new LocalizationService("en-US"); + + var value = _service.Get("non.existing.key", "fallback"); + + Assert.Equal("fallback", value); + } + + [Fact] + public void Get_NullKey_ReturnsEmpty() + { + _service = new LocalizationService("en-US"); + + var value = _service.Get(null!); + + Assert.Equal(string.Empty, value); + } + + [Fact] + public void Get_EmptyKey_ReturnsEmpty() + { + _service = new LocalizationService("en-US"); + + var value = _service.Get(string.Empty); + + Assert.Equal(string.Empty, value); + } + + [Fact] + public void Get_NullKey_WithDefault_ReturnsDefault() + { + _service = new LocalizationService("en-US"); + + var value = _service.Get(null!, "my default"); + + Assert.Equal("my default", value); + } + + [Fact] + public void Get_SpanishLanguage_ReturnsSpanishValues() + { + _service = new LocalizationService("es-CL"); + + var value = _service.Get("clipboard.contextMenu.paste"); + + Assert.NotNull(value); + Assert.NotEmpty(value); + } + + #endregion + + #region Dispose Tests + + [Fact] + public void Dispose_AfterDispose_GetThrows() + { + _service = new LocalizationService("en-US"); + _service.Dispose(); + + Assert.Throws(() => _service.Get("any.key")); + } + + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + _service = new LocalizationService("en-US"); + _service.Dispose(); + _service.Dispose(); + } + + #endregion + + #region Language Resolution Tests + + [Fact] + public void CurrentLanguage_IsAlwaysSet() + { + _service = new LocalizationService(); + + Assert.NotNull(_service.CurrentLanguage); + Assert.NotEmpty(_service.CurrentLanguage); + } + + [Fact] + public void Constructor_LoadsKeysSuccessfully() + { + _service = new LocalizationService("en-US"); + + var clipboardKey = _service.Get("clipboard.contextMenu.delete"); + Assert.NotEqual("[clipboard.contextMenu.delete]", clipboardKey); + } + + #endregion + + public void Dispose() + { + _service?.Dispose(); + } +} diff --git a/Tests/CopyPaste.UI.Tests/ViewModelAdditionalTests.cs b/Tests/CopyPaste.UI.Tests/ViewModelAdditionalTests.cs new file mode 100644 index 0000000..0a61532 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ViewModelAdditionalTests.cs @@ -0,0 +1,322 @@ +using System.Collections.ObjectModel; +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class DefaultThemeViewModelTests +{ + private static DefaultThemeViewModel CreateViewModel(IClipboardService? service = null, DefaultThemeSettings? settings = null) + { + service ??= new StubClipboardService(); + settings ??= new DefaultThemeSettings(); + return new DefaultThemeViewModel(service, new MyMConfig(), settings); + } + + [Fact] + public void Constructor_SetsDefaults() + { + var vm = CreateViewModel(); + + Assert.True(vm.IsEmpty); + Assert.Equal(string.Empty, vm.SearchQuery); + Assert.False(vm.HasSearchQuery); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.Equal(0, vm.ActiveFilterMode); + Assert.False(vm.IsLoadingMore); + } + + [Fact] + public void IsWindowPinned_Default_IsFalse() + { + var vm = CreateViewModel(); + + Assert.False(vm.IsWindowPinned); + } + + [Fact] + public void IsWindowPinned_WhenPinWindowTrue_ReturnsTrue() + { + var settings = new DefaultThemeSettings { PinWindow = true }; + var vm = CreateViewModel(settings: settings); + + Assert.True(vm.IsWindowPinned); + } + + [Fact] + public void SearchQuery_Set_UpdatesHasSearchQuery() + { + var vm = CreateViewModel(); + + vm.SearchQuery = "hello"; + + Assert.True(vm.HasSearchQuery); + } + + [Fact] + public void SearchQuery_Clear_UpdatesHasSearchQuery() + { + var vm = CreateViewModel(); + vm.SearchQuery = "hello"; + + vm.SearchQuery = string.Empty; + + Assert.False(vm.HasSearchQuery); + } + + [Fact] + public void IsContentFilterMode_DefaultIsTrue() + { + var vm = CreateViewModel(); + + Assert.True(vm.IsContentFilterMode); + } + + [Fact] + public void IsCategoryFilterMode_DefaultIsFalse() + { + var vm = CreateViewModel(); + + Assert.False(vm.IsCategoryFilterMode); + } + + [Fact] + public void IsTypeFilterMode_DefaultIsFalse() + { + var vm = CreateViewModel(); + + Assert.False(vm.IsTypeFilterMode); + } + + [Fact] + public void ActiveFilterMode_Set1_IsCategoryTrue() + { + var vm = CreateViewModel(); + + vm.ActiveFilterMode = 1; + + Assert.False(vm.IsContentFilterMode); + Assert.True(vm.IsCategoryFilterMode); + Assert.False(vm.IsTypeFilterMode); + } + + [Fact] + public void ActiveFilterMode_Set2_IsTypeTrue() + { + var vm = CreateViewModel(); + + vm.ActiveFilterMode = 2; + + Assert.False(vm.IsContentFilterMode); + Assert.False(vm.IsCategoryFilterMode); + Assert.True(vm.IsTypeFilterMode); + } + + [Fact] + public void Items_InitiallyEmpty() + { + var vm = CreateViewModel(); + + Assert.Empty(vm.Items); + } + + #region Filter Tests (IsColorSelected / ToggleColorFilter / IsTypeSelected / ToggleTypeFilter) + + [Fact] + public void IsColorSelected_InitialState_ReturnsFalse() + { + var vm = CreateViewModel(); + + Assert.False(vm.IsColorSelected(CardColor.Red)); + Assert.False(vm.IsColorSelected(CardColor.Green)); + } + + [Fact] + public void ToggleColorFilter_SelectsColor() + { + var vm = CreateViewModel(); + + vm.ToggleColorFilter(CardColor.Red); + + Assert.True(vm.IsColorSelected(CardColor.Red)); + Assert.False(vm.IsColorSelected(CardColor.Green)); + } + + [Fact] + public void ToggleColorFilter_Twice_DeselectsColor() + { + var vm = CreateViewModel(); + vm.ToggleColorFilter(CardColor.Red); + + vm.ToggleColorFilter(CardColor.Red); + + Assert.False(vm.IsColorSelected(CardColor.Red)); + } + + [Fact] + public void ClearColorFilters_DeselectsAll() + { + var vm = CreateViewModel(); + vm.ToggleColorFilter(CardColor.Red); + vm.ToggleColorFilter(CardColor.Green); + + vm.ClearColorFilters(); + + Assert.False(vm.IsColorSelected(CardColor.Red)); + Assert.False(vm.IsColorSelected(CardColor.Green)); + } + + [Fact] + public void IsTypeSelected_InitialState_ReturnsFalse() + { + var vm = CreateViewModel(); + + Assert.False(vm.IsTypeSelected(ClipboardContentType.Text)); + Assert.False(vm.IsTypeSelected(ClipboardContentType.Image)); + } + + [Fact] + public void ToggleTypeFilter_SelectsType() + { + var vm = CreateViewModel(); + + vm.ToggleTypeFilter(ClipboardContentType.Text); + + Assert.True(vm.IsTypeSelected(ClipboardContentType.Text)); + Assert.False(vm.IsTypeSelected(ClipboardContentType.Image)); + } + + [Fact] + public void ToggleTypeFilter_Twice_DeselectsType() + { + var vm = CreateViewModel(); + vm.ToggleTypeFilter(ClipboardContentType.Text); + + vm.ToggleTypeFilter(ClipboardContentType.Text); + + Assert.False(vm.IsTypeSelected(ClipboardContentType.Text)); + } + + [Fact] + public void ClearTypeFilters_DeselectsAll() + { + var vm = CreateViewModel(); + vm.ToggleTypeFilter(ClipboardContentType.Text); + vm.ToggleTypeFilter(ClipboardContentType.Image); + + vm.ClearTypeFilters(); + + Assert.False(vm.IsTypeSelected(ClipboardContentType.Text)); + Assert.False(vm.IsTypeSelected(ClipboardContentType.Image)); + } + + #endregion + + #region OpenRepoCommand Tests + + [Fact] + public void OpenRepoCommand_IsNotNull() + { + var vm = CreateViewModel(); + + Assert.NotNull(vm.OpenRepoCommand); + } + + [Fact] + public void OpenRepoCommand_CanExecute_ReturnsTrue() + { + var vm = CreateViewModel(); + + Assert.True(vm.OpenRepoCommand.CanExecute(null)); + } + + #endregion + + private sealed class StubClipboardService : IClipboardService + { + public event Action? OnItemAdded; + public event Action? OnThumbnailReady; + public event Action? OnItemReactivated; + public List RemovedIds { get; } = []; + public void AddText(string? content, ClipboardContentType type, string? appSource, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { } + public void AddImage(byte[]? dibData, string? appSource) { } + public void AddFiles(Collection? filePaths, ClipboardContentType type, string? appSource) { } + public IEnumerable GetHistory(int limit = 50, int skip = 0, string? searchQuery = null, bool? isPinned = null) => []; + public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned) => []; + public void RemoveItem(Guid id) => RemovedIds.Add(id); + public void UpdatePin(Guid id, bool isPinned) { } + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } + public int PasteIgnoreWindowMs { get; set; } = 450; + public void FireEvents() { OnItemAdded?.Invoke(null!); OnThumbnailReady?.Invoke(null!); OnItemReactivated?.Invoke(null!); } + } +} + +public sealed class CompactViewModelTests +{ + private static CompactViewModel CreateViewModel(IClipboardService? service = null, CompactSettings? settings = null) + { + service ??= new StubClipboardService(); + settings ??= new CompactSettings(); + return new CompactViewModel(service, new MyMConfig(), settings); + } + + [Fact] + public void Constructor_SetsDefaults() + { + var vm = CreateViewModel(); + + Assert.True(vm.IsEmpty); + Assert.Equal(string.Empty, vm.SearchQuery); + Assert.False(vm.HasSearchQuery); + Assert.Equal(0, vm.SelectedTabIndex); + Assert.Equal(0, vm.ActiveFilterMode); + } + + [Fact] + public void IsWindowPinned_Default_IsFalse() + { + var vm = CreateViewModel(); + + Assert.False(vm.IsWindowPinned); + } + + [Fact] + public void IsWindowPinned_WhenPinWindowTrue_ReturnsTrue() + { + var settings = new CompactSettings { PinWindow = true }; + var vm = CreateViewModel(settings: settings); + + Assert.True(vm.IsWindowPinned); + } + + [Fact] + public void Items_InitiallyEmpty() + { + var vm = CreateViewModel(); + + Assert.Empty(vm.Items); + } + + private sealed class StubClipboardService : IClipboardService + { + public event Action? OnItemAdded; + public event Action? OnThumbnailReady; + public event Action? OnItemReactivated; + public List RemovedIds { get; } = []; + public void AddText(string? content, ClipboardContentType type, string? appSource, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { } + public void AddImage(byte[]? dibData, string? appSource) { } + public void AddFiles(Collection? filePaths, ClipboardContentType type, string? appSource) { } + public IEnumerable GetHistory(int limit = 50, int skip = 0, string? searchQuery = null, bool? isPinned = null) => []; + public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned) => []; + public void RemoveItem(Guid id) => RemovedIds.Add(id); + public void UpdatePin(Guid id, bool isPinned) { } + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } + public int PasteIgnoreWindowMs { get; set; } = 450; + public void FireEvents() { OnItemAdded?.Invoke(null!); OnThumbnailReady?.Invoke(null!); OnItemReactivated?.Invoke(null!); } + } +} diff --git a/Tests/CopyPaste.UI.Tests/ViewModelResetAndLoadTests.cs b/Tests/CopyPaste.UI.Tests/ViewModelResetAndLoadTests.cs new file mode 100644 index 0000000..c53a807 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ViewModelResetAndLoadTests.cs @@ -0,0 +1,517 @@ +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Xunit; + +namespace CopyPaste.UI.Tests; + +public sealed class ViewModelResetFiltersTests +{ + private static DefaultThemeViewModel CreateViewModel(IClipboardService? service = null) + { + service ??= new EmptyStubService(); + return new DefaultThemeViewModel(service, new MyMConfig(), new DefaultThemeSettings()); + } + + [Fact] + public void ResetFilters_WithResetModeTrue_ClearsSearchQuery() + { + var vm = CreateViewModel(); + vm.SearchQuery = "hello"; + + vm.ResetFilters(resetMode: true, content: false, category: false, type: false); + + Assert.Equal(string.Empty, vm.SearchQuery); + } + + [Fact] + public void ResetFilters_WithResetModeTrue_ClearsColorFilter() + { + var vm = CreateViewModel(); + vm.ToggleColorFilter(CardColor.Red); + + vm.ResetFilters(resetMode: true, content: false, category: false, type: false); + + Assert.False(vm.IsColorSelected(CardColor.Red)); + } + + [Fact] + public void ResetFilters_WithResetModeTrue_ClearsTypeFilter() + { + var vm = CreateViewModel(); + vm.ToggleTypeFilter(ClipboardContentType.Text); + + vm.ResetFilters(resetMode: true, content: false, category: false, type: false); + + Assert.False(vm.IsTypeSelected(ClipboardContentType.Text)); + } + + [Fact] + public void ResetFilters_WithResetModeTrue_ResetsFilterModeToZero() + { + var vm = CreateViewModel(); + vm.ActiveFilterMode = 1; + + vm.ResetFilters(resetMode: true, content: false, category: false, type: false); + + Assert.Equal(0, vm.ActiveFilterMode); + } + + [Fact] + public void ResetFilters_WithResetModeFalse_ContentOnly_ClearsOnlySearchQuery() + { + var vm = CreateViewModel(); + vm.SearchQuery = "hello"; + vm.ToggleColorFilter(CardColor.Blue); + + vm.ResetFilters(resetMode: false, content: true, category: false, type: false); + + Assert.Equal(string.Empty, vm.SearchQuery); + Assert.True(vm.IsColorSelected(CardColor.Blue)); + } + + [Fact] + public void ResetFilters_WithResetModeFalse_CategoryOnly_ClearsOnlyColors() + { + var vm = CreateViewModel(); + vm.SearchQuery = "hello"; + vm.ToggleColorFilter(CardColor.Red); + + vm.ResetFilters(resetMode: false, content: false, category: true, type: false); + + Assert.Equal("hello", vm.SearchQuery); + Assert.False(vm.IsColorSelected(CardColor.Red)); + } + + [Fact] + public void ResetFilters_WithResetModeFalse_TypeOnly_ClearsOnlyTypes() + { + var vm = CreateViewModel(); + vm.ToggleColorFilter(CardColor.Green); + vm.ToggleTypeFilter(ClipboardContentType.Image); + + vm.ResetFilters(resetMode: false, content: false, category: false, type: true); + + Assert.True(vm.IsColorSelected(CardColor.Green)); + Assert.False(vm.IsTypeSelected(ClipboardContentType.Image)); + } + + [Fact] + public void ResetFilters_NothingActive_DoesNotThrow() + { + var vm = CreateViewModel(); + + var exception = Record.Exception(() => + vm.ResetFilters(resetMode: true, content: true, category: true, type: true)); + + Assert.Null(exception); + Assert.Equal(string.Empty, vm.SearchQuery); + Assert.Equal(0, vm.ActiveFilterMode); + } + + [Fact] + public void ResetFilters_WithResetModeFalse_AllFlagsTrue_ClearsAllFilters() + { + var vm = CreateViewModel(); + vm.SearchQuery = "test"; + vm.ToggleColorFilter(CardColor.Red); + vm.ToggleTypeFilter(ClipboardContentType.File); + + vm.ResetFilters(resetMode: false, content: true, category: true, type: true); + + Assert.Equal(string.Empty, vm.SearchQuery); + Assert.False(vm.IsColorSelected(CardColor.Red)); + Assert.False(vm.IsTypeSelected(ClipboardContentType.File)); + } + + [Fact] + public void ResetFilters_WithResetModeTrue_ClearsMultipleColors() + { + var vm = CreateViewModel(); + vm.ToggleColorFilter(CardColor.Red); + vm.ToggleColorFilter(CardColor.Blue); + + vm.ResetFilters(resetMode: true, content: false, category: false, type: false); + + Assert.False(vm.IsColorSelected(CardColor.Red)); + Assert.False(vm.IsColorSelected(CardColor.Blue)); + } +} + +public sealed class ViewModelLoadMoreItemsTests +{ + private static (DefaultThemeViewModel vm, ItemsStubService service) CreateVmWithItems( + List items, int pageSize = 2) + { + var service = new ItemsStubService(items); + var config = new MyMConfig { PageSize = pageSize, MaxItemsBeforeCleanup = 100 }; + var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings()); + return (vm, service); + } + + [Fact] + public void LoadMoreItems_AppendsNextPage() + { + var items = Enumerable.Range(0, 5) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); // loads first page: 2 items + + vm.LoadMoreItems(); // loads second page: 2 more + + Assert.Equal(4, vm.Items.Count); + } + + [Fact] + public void LoadMoreItems_WhenEmptyResult_SetsNoMoreItems() + { + var items = Enumerable.Range(0, 2) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); // loads items 0,1 + + vm.LoadMoreItems(); // skip=2, returns empty → no more items + vm.LoadMoreItems(); // should be no-op now + + Assert.Equal(2, vm.Items.Count); + } + + [Fact] + public void LoadMoreItems_MultiplePagesUntilExhausted_CountIsCorrect() + { + var items = Enumerable.Range(0, 4) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); // 2 items + + vm.LoadMoreItems(); // +2, total 4 + vm.LoadMoreItems(); // returns empty, no more items + vm.LoadMoreItems(); // no-op + + Assert.Equal(4, vm.Items.Count); + } + + [Fact] + public void LoadMoreItems_IsLoadingMore_FalseAfterCompletion() + { + var items = Enumerable.Range(0, 3) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); + + vm.LoadMoreItems(); + + Assert.False(vm.IsLoadingMore); + } +} + +public sealed class ViewModelWindowDeactivatedTests +{ + private static (DefaultThemeViewModel vm, ItemsStubService service) CreateVmWithSmallConfig( + List items, int pageSize = 2, int maxCleanup = 3) + { + var service = new ItemsStubService(items); + var config = new MyMConfig { PageSize = pageSize, MaxItemsBeforeCleanup = maxCleanup }; + var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings()); + return (vm, service); + } + + [Fact] + public void OnWindowDeactivated_BelowThreshold_DoesNotTrimItems() + { + var items = Enumerable.Range(0, 5) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithSmallConfig(items, pageSize: 2, maxCleanup: 3); + vm.ToggleColorFilter(CardColor.Red); // loads 2 items (< maxCleanup=3) + + vm.OnWindowDeactivated(); // 2 <= 3, no trim + + Assert.Equal(2, vm.Items.Count); + } + + [Fact] + public void OnWindowDeactivated_AboveThreshold_TrimsToPageSize() + { + var items = Enumerable.Range(0, 5) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithSmallConfig(items, pageSize: 2, maxCleanup: 3); + vm.ToggleColorFilter(CardColor.Red); // 2 items + vm.LoadMoreItems(); // +2 → 4 items (> maxCleanup=3) + + vm.OnWindowDeactivated(); + + Assert.Equal(2, vm.Items.Count); + } + + [Fact] + public void OnWindowDeactivated_AboveThreshold_AllowsLoadMoreAfterwards() + { + var items = Enumerable.Range(0, 5) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithSmallConfig(items, pageSize: 2, maxCleanup: 3); + vm.ToggleColorFilter(CardColor.Red); // 2 items + vm.LoadMoreItems(); // 4 items + vm.OnWindowDeactivated(); // trims to 2, resets _hasMoreItems = true + + vm.LoadMoreItems(); // should load 2 more from skip=2 + + Assert.Equal(4, vm.Items.Count); + } + + [Fact] + public void OnWindowDeactivated_ExactlyAtThreshold_DoesNotTrim() + { + var items = Enumerable.Range(0, 5) + .Select(i => new ClipboardItem { Content = $"item{i}", Type = ClipboardContentType.Text }) + .ToList(); + var (vm, _) = CreateVmWithSmallConfig(items, pageSize: 2, maxCleanup: 2); + vm.ToggleColorFilter(CardColor.Red); // loads exactly 2 items = maxCleanup=2 + + vm.OnWindowDeactivated(); // 2 <= 2, no trim + + Assert.Equal(2, vm.Items.Count); + } +} + +public sealed class ViewModelSaveItemAndColorTests +{ + private static (DefaultThemeViewModel vm, ItemsStubService service) CreateVm() + { + var service = new ItemsStubService([]); + var vm = new DefaultThemeViewModel(service, new MyMConfig(), new DefaultThemeSettings()); + return (vm, service); + } + + [Fact] + public void SaveItemLabelAndColor_UpdatesModelLabel() + { + var (vm, _) = CreateVm(); + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var itemVM = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + vm.SaveItemLabelAndColor(itemVM, "myLabel", CardColor.Blue); + + Assert.Equal("myLabel", model.Label); + } + + [Fact] + public void SaveItemLabelAndColor_UpdatesModelColor() + { + var (vm, _) = CreateVm(); + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var itemVM = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + vm.SaveItemLabelAndColor(itemVM, null, CardColor.Green); + + Assert.Equal(CardColor.Green, model.CardColor); + } + + [Fact] + public void SaveItemLabelAndColor_CallsServiceUpdate() + { + var (vm, service) = CreateVm(); + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var itemVM = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + vm.SaveItemLabelAndColor(itemVM, "label", CardColor.Red); + + Assert.Single(service.LabelColorUpdates); + var (id, label, color) = service.LabelColorUpdates[0]; + Assert.Equal(model.Id, id); + Assert.Equal("label", label); + Assert.Equal(CardColor.Red, color); + } + + [Fact] + public void SaveItemLabelAndColor_WithNullItemVM_ThrowsArgumentNullException() + { + var (vm, _) = CreateVm(); + + Assert.Throws(() => + vm.SaveItemLabelAndColor(null!, "label", CardColor.None)); + } + + [Fact] + public void SaveItemLabelAndColor_WithNullLabel_SetsLabelToNull() + { + var (vm, _) = CreateVm(); + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text, Label = "existing" }; + var itemVM = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + vm.SaveItemLabelAndColor(itemVM, null, CardColor.None); + + Assert.Null(model.Label); + } + + [Fact] + public void SaveItemLabelAndColor_FiresPropertyChangedForLabel() + { + var (vm, _) = CreateVm(); + var model = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var itemVM = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + var changedProps = new List(); + itemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.SaveItemLabelAndColor(itemVM, "newLabel", CardColor.Purple); + + Assert.Contains(nameof(ClipboardItemViewModel.Label), changedProps); + } +} + +public sealed class DefaultThemeViewModelClearAllTests +{ + private static (DefaultThemeViewModel vm, ItemsStubService service) CreateVmWithItems( + List items, int pageSize = 10) + { + var service = new ItemsStubService(items); + var config = new MyMConfig { PageSize = pageSize, MaxItemsBeforeCleanup = 100 }; + var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings()); + return (vm, service); + } + + [Fact] + public void ClearAll_RemovesUnpinnedItems_KeepsPinned() + { + var items = new List + { + new() { Content = "pinned", Type = ClipboardContentType.Text, IsPinned = true }, + new() { Content = "unpinned1", Type = ClipboardContentType.Text, IsPinned = false }, + new() { Content = "unpinned2", Type = ClipboardContentType.Text, IsPinned = false }, + }; + var (vm, _) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); // loads all 3 items + + vm.ClearAllCommand.Execute(null); + + var remaining = Assert.Single(vm.Items); + Assert.True(remaining.IsPinned); + } + + [Fact] + public void ClearAll_CallsServiceRemoveForEachUnpinned() + { + var unpinned1 = new ClipboardItem { Content = "a", Type = ClipboardContentType.Text, IsPinned = false }; + var unpinned2 = new ClipboardItem { Content = "b", Type = ClipboardContentType.Text, IsPinned = false }; + var pinned = new ClipboardItem { Content = "c", Type = ClipboardContentType.Text, IsPinned = true }; + var (vm, service) = CreateVmWithItems([unpinned1, unpinned2, pinned]); + vm.ToggleColorFilter(CardColor.Red); + + vm.ClearAllCommand.Execute(null); + + Assert.Equal(2, service.RemovedIds.Count); + Assert.Contains(unpinned1.Id, service.RemovedIds); + Assert.Contains(unpinned2.Id, service.RemovedIds); + Assert.DoesNotContain(pinned.Id, service.RemovedIds); + } + + [Fact] + public void ClearAll_WithAllPinned_RemovesNothing() + { + var items = new List + { + new() { Content = "p1", Type = ClipboardContentType.Text, IsPinned = true }, + new() { Content = "p2", Type = ClipboardContentType.Text, IsPinned = true }, + }; + var (vm, service) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); + + vm.ClearAllCommand.Execute(null); + + Assert.Equal(2, vm.Items.Count); + Assert.Empty(service.RemovedIds); + } + + [Fact] + public void ClearAll_WithAllUnpinned_RemovesAll() + { + var items = new List + { + new() { Content = "u1", Type = ClipboardContentType.Text, IsPinned = false }, + new() { Content = "u2", Type = ClipboardContentType.Text, IsPinned = false }, + new() { Content = "u3", Type = ClipboardContentType.Text, IsPinned = false }, + }; + var (vm, service) = CreateVmWithItems(items); + vm.ToggleColorFilter(CardColor.Red); + + vm.ClearAllCommand.Execute(null); + + Assert.Empty(vm.Items); + Assert.Equal(3, service.RemovedIds.Count); + } + + [Fact] + public void ClearAll_EmptyItems_DoesNotThrow() + { + var (vm, service) = CreateVmWithItems([]); + + var exception = Record.Exception(() => vm.ClearAllCommand.Execute(null)); + + Assert.Null(exception); + Assert.Empty(service.RemovedIds); + } +} + +// Shared stubs used by all test classes above. + +internal sealed class EmptyStubService : IClipboardService +{ +#pragma warning disable CS0067 + public event Action? OnItemAdded; + public event Action? OnThumbnailReady; + public event Action? OnItemReactivated; +#pragma warning restore CS0067 + public int PasteIgnoreWindowMs { get; set; } = 450; + public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { } + public void AddImage(byte[]? dibData, string? source) { } + public void AddFiles(Collection? files, ClipboardContentType type, string? source) { } + public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => []; + public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned) => []; + public void RemoveItem(Guid id) { } + public void UpdatePin(Guid id, bool isPinned) { } + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } +} + +internal sealed class ItemsStubService : IClipboardService +{ + private readonly List _items; + + public List<(Guid id, string? label, CardColor color)> LabelColorUpdates { get; } = []; + public List RemovedIds { get; } = []; + + public ItemsStubService(List items) => _items = items; + +#pragma warning disable CS0067 + public event Action? OnItemAdded; + public event Action? OnThumbnailReady; + public event Action? OnItemReactivated; +#pragma warning restore CS0067 + public int PasteIgnoreWindowMs { get; set; } = 450; + + public IEnumerable GetHistoryAdvanced(int limit, int skip, string? query, IReadOnlyCollection? types, IReadOnlyCollection? colors, bool? isPinned) + => _items.Skip(skip).Take(limit); + + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) + => LabelColorUpdates.Add((id, label, color)); + + public void RemoveItem(Guid id) => RemovedIds.Add(id); + + public void AddText(string? text, ClipboardContentType type, string? source, byte[]? rtfBytes = null, byte[]? htmlBytes = null) { } + public void AddImage(byte[]? dibData, string? source) { } + public void AddFiles(Collection? files, ClipboardContentType type, string? source) { } + public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => []; + public void UpdatePin(Guid id, bool isPinned) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } +} diff --git a/coverage.core.runsettings b/coverage.core.runsettings new file mode 100644 index 0000000..7832d8b --- /dev/null +++ b/coverage.core.runsettings @@ -0,0 +1,18 @@ + + + + + + + opencover + [CopyPaste.Core]* + [CopyPaste.Core.Tests]*,[xunit.*]*,[Microsoft.*]* + **/AssemblyInfo.cs,**/GlobalSuppressions.cs,**/*.g.cs,**/*.designer.cs + System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute,System.CodeDom.Compiler.GeneratedCodeAttribute,System.Runtime.CompilerServices.CompilerGeneratedAttribute + false + false + + + + + diff --git a/coverage.listener.runsettings b/coverage.listener.runsettings new file mode 100644 index 0000000..8bcbe6b --- /dev/null +++ b/coverage.listener.runsettings @@ -0,0 +1,18 @@ + + + + + + + opencover + [CopyPaste.Listener]* + [CopyPaste.Listener.Tests]*,[xunit.*]*,[Microsoft.*]* + **/AssemblyInfo.cs,**/GlobalSuppressions.cs,**/*.g.cs,**/*.designer.cs + System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute,System.CodeDom.Compiler.GeneratedCodeAttribute,System.Runtime.CompilerServices.CompilerGeneratedAttribute + false + false + + + + + diff --git a/coverage.runsettings b/coverage.runsettings index 26fba4f..a1c73c3 100644 --- a/coverage.runsettings +++ b/coverage.runsettings @@ -5,6 +5,12 @@ opencover + [CopyPaste.App]*,[CopyPaste.Core]*,[CopyPaste.Listener]* + [CopyPaste.Core.Tests]*,[CopyPaste.Listener.Tests]*,[CopyPaste.UI.Tests]*,[xunit.*]*,[Microsoft.*]* + **/AssemblyInfo.cs,**/GlobalSuppressions.cs,**/*.g.cs,**/*.designer.cs + System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute,System.CodeDom.Compiler.GeneratedCodeAttribute + false + false diff --git a/coverage.ui.runsettings b/coverage.ui.runsettings new file mode 100644 index 0000000..9f619ff --- /dev/null +++ b/coverage.ui.runsettings @@ -0,0 +1,18 @@ + + + + + + + opencover + [CopyPaste.App]* + [CopyPaste.UI.Tests]*,[xunit.*]*,[Microsoft.*]* + **/AssemblyInfo.cs,**/GlobalSuppressions.cs,**/*.g.cs,**/*.designer.cs + System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute,System.CodeDom.Compiler.GeneratedCodeAttribute,System.Runtime.CompilerServices.CompilerGeneratedAttribute + false + false + + + + +