diff --git a/CopyPaste.Core/MyMConfig.cs b/CopyPaste.Core/MyMConfig.cs index 85facf6..1f2a581 100644 --- a/CopyPaste.Core/MyMConfig.cs +++ b/CopyPaste.Core/MyMConfig.cs @@ -37,7 +37,13 @@ public sealed class MyMConfig /// ID of the active theme (e.g., "copypaste.default"). /// Must match . /// - public string ThemeId { get; set; } = "copypaste.default"; + public string ThemeId { get; set; } = "copypaste.compact"; + + /// + /// Whether the user has already seen (and dismissed) the theme discovery hint in Settings. + /// Once true, the hint banner is never shown again. + /// + public bool HasSeenThemeHint { get; set; } // ═══════════════════════════════════════════════════════════════ // Hotkey Configuration diff --git a/CopyPaste.UI/App.xaml.cs b/CopyPaste.UI/App.xaml.cs index 0a8161f..a3bb0fa 100644 --- a/CopyPaste.UI/App.xaml.cs +++ b/CopyPaste.UI/App.xaml.cs @@ -87,6 +87,7 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) InitializeCoreServices(); var config = _engine!.Config; + MigrateFirstRunToCompact(config); L.Initialize(new LocalizationService(config.PreferredLanguage)); _themeRegistry = new ThemeRegistry(); @@ -113,6 +114,17 @@ protected override void OnLaunched(LaunchActivatedEventArgs args) AppLogger.Info("Main window launched"); } + // Ensures fresh installs and users who never changed their theme get Compact by default. + // Only runs once (guarded by HasSeenThemeHint = false on first run). + private static void MigrateFirstRunToCompact(MyMConfig config) + { + if (!config.HasSeenThemeHint && config.ThemeId == "copypaste.default") + { + config.ThemeId = "copypaste.compact"; + ConfigLoader.Save(config); + } + } + private void InitializeCoreServices() { _engine = new CopyPasteEngine(svc => new WindowsClipboardListener(svc)); diff --git a/CopyPaste.UI/Localization/Languages/en-US.json b/CopyPaste.UI/Localization/Languages/en-US.json index 98858a0..2b14c2b 100644 --- a/CopyPaste.UI/Localization/Languages/en-US.json +++ b/CopyPaste.UI/Localization/Languages/en-US.json @@ -24,7 +24,11 @@ "selectColors": "Select colors...", "selectTypes": "Select types..." }, - "emptyState": "No items in this section" + "emptyState": "No items in this section", + "firstRun": { + "text": "Customize your experience in", + "action": "Settings" + } }, "config": { "window": { diff --git a/CopyPaste.UI/Localization/Languages/es-CL.json b/CopyPaste.UI/Localization/Languages/es-CL.json index f5e476d..bb88b6f 100644 --- a/CopyPaste.UI/Localization/Languages/es-CL.json +++ b/CopyPaste.UI/Localization/Languages/es-CL.json @@ -24,7 +24,11 @@ "selectColors": "Seleccionar colores...", "selectTypes": "Seleccionar tipos..." }, - "emptyState": "No hay elementos en esta sección" + "emptyState": "No hay elementos en esta sección", + "firstRun": { + "text": "Personaliza tu experiencia en", + "action": "Ajustes" + } }, "config": { "window": { diff --git a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml index eddf50c..7b20ceb 100644 --- a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml +++ b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml @@ -15,6 +15,7 @@ + @@ -91,7 +92,7 @@ - + @@ -272,8 +273,32 @@ + + + + + + + + + + + + + - - - + + + MaxLines="1" VerticalAlignment="Center" + Visibility="{x:Bind DefaultHeaderVisibility, Mode=OneWay}"/> @@ -443,17 +470,18 @@ - - + + - + + MaxLines="1" TextTrimming="CharacterEllipsis" MaxWidth="140" + VerticalAlignment="Center"/> - + "copypaste.default"; - public string Name => "Default"; + public string Name => "Full"; public string Version => "1.0.0"; public string Author => "CopyPaste"; diff --git a/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs b/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs index 8080524..aae61b9 100644 --- a/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs +++ b/Tests/CopyPaste.Core.Tests/MyMConfigTests.cs @@ -23,10 +23,10 @@ public void DefaultConfig_RunOnStartup_IsTrue() } [Fact] - public void DefaultConfig_ThemeId_IsDefault() + public void DefaultConfig_ThemeId_IsCompact() { var config = new MyMConfig(); - Assert.Equal("copypaste.default", config.ThemeId); + Assert.Equal("copypaste.compact", config.ThemeId); } [Fact] diff --git a/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelMediaTests.cs b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelMediaTests.cs new file mode 100644 index 0000000..cc29207 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ClipboardItemViewModelMediaTests.cs @@ -0,0 +1,571 @@ +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using Microsoft.UI.Xaml; +using System; +using System.IO; +using Xunit; + +namespace CopyPaste.UI.Tests; + +/// +/// Tests for ClipboardItemViewModel menu text properties and media-related members +/// (ThumbnailPath, ImagePath, IsFileAvailable, etc.) that are not covered elsewhere. +/// +public sealed class ClipboardItemViewModelMenuTextTests +{ + private static ClipboardItemViewModel Create( + ClipboardItem model, + Action? editAction = null) => + new(model, _ => { }, (_, _) => { }, _ => { }, editAction); + + [Fact] + public void PasteText_ReturnsNonEmptyString() + { + var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text }); + + Assert.NotEmpty(vm.PasteText); + } + + [Fact] + public void PastePlainText_ReturnsNonEmptyString() + { + var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text }); + + Assert.NotEmpty(vm.PastePlainText); + } + + [Fact] + public void DeleteText_ReturnsNonEmptyString() + { + var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text }); + + Assert.NotEmpty(vm.DeleteText); + } + + [Fact] + public void EditText_ReturnsNonEmptyString() + { + var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text }); + + Assert.NotEmpty(vm.EditText); + } + + [Fact] + public void FileWarningText_ReturnsNonEmptyString() + { + var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.File }); + + Assert.NotEmpty(vm.FileWarningText); + } + + [Fact] + public void HeaderTitle_ForTextType_ReturnsNonEmpty() + { + var vm = Create(new ClipboardItem { Content = "x", Type = ClipboardContentType.Text }); + + Assert.NotEmpty(vm.HeaderTitle); + } + + [Fact] + public void PinMenuText_WhenUnpinned_ReturnsNonEmpty() + { + var model = new ClipboardItem { Content = "x", Type = ClipboardContentType.Text, IsPinned = false }; + var vm = Create(model); + + Assert.NotEmpty(vm.PinMenuText); + } + + [Fact] + public void PinMenuText_WhenPinned_ReturnsNonEmpty() + { + var model = new ClipboardItem { Content = "x", Type = ClipboardContentType.Text, IsPinned = true }; + var vm = Create(model); + + Assert.NotEmpty(vm.PinMenuText); + } +} + +public sealed class ClipboardItemViewModelFileAvailabilityTests +{ + [Fact] + public void IsFileAvailable_ForTextItem_AlwaysTrue() + { + var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.True(vm.IsFileAvailable); + } + + [Fact] + public void FileWarningVisibility_ForTextItem_IsCollapsed() + { + var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.Equal(Visibility.Collapsed, vm.FileWarningVisibility); + } + + [Fact] + public void IsFileAvailable_ForFileItemWithMissingPath_IsFalse() + { + var model = new ClipboardItem + { + Content = @"C:\this_path_does_not_exist_xyz\file.txt", + Type = ClipboardContentType.File + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.False(vm.IsFileAvailable); + } + + [Fact] + public void FileWarningVisibility_ForMissingFile_IsVisible() + { + var model = new ClipboardItem + { + Content = @"C:\this_path_does_not_exist_xyz\file.txt", + Type = ClipboardContentType.File + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.Equal(Visibility.Visible, vm.FileWarningVisibility); + } +} + +public sealed class ClipboardItemViewModelImageTests +{ + [Fact] + public void HasValidImagePath_ForTextItem_IsFalse() + { + var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.False(vm.HasValidImagePath); + } + + [Fact] + public void ImageVisibility_ForTextItem_IsCollapsed() + { + var model = new ClipboardItem { Content = "hello", Type = ClipboardContentType.Text }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.Equal(Visibility.Collapsed, vm.ImageVisibility); + } + + [Fact] + public void HasValidImagePath_ForImageItemWithNoMetadata_IsTrue() + { + // For Image type, GetImagePathOrThumbnail returns the ms-appx placeholder, + // so HasValidImagePath = true even without a real file on disk. + var model = new ClipboardItem + { + Content = @"C:\nonexistent\image.png", + Type = ClipboardContentType.Image, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.True(vm.HasValidImagePath); + } + + [Fact] + public void ImageVisibility_ForImageItem_IsVisible() + { + var model = new ClipboardItem + { + Content = @"C:\nonexistent\image.png", + Type = ClipboardContentType.Image, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + Assert.Equal(Visibility.Visible, vm.ImageVisibility); + } + + [Fact] + public void ImagePath_ForImageItemWithNoMetadata_ReturnsPlaceholder() + { + var model = new ClipboardItem + { + Content = @"C:\nonexistent\file.png", + Type = ClipboardContentType.Image, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ImagePath; + + // Should return ms-appx placeholder for Image type when no valid path + Assert.NotEmpty(path); + } + + [Fact] + public void ImagePath_SecondAccess_UsesCachedValue() + { + var model = new ClipboardItem + { + Content = @"C:\nonexistent\file.png", + Type = ClipboardContentType.Image, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var first = vm.ImagePath; + var second = vm.ImagePath; + + Assert.Equal(first, second); + } + + [Fact] + public void ImagePath_ForImageItemWithThumbPathMetadata_ReturnsExpected() + { + var metadata = """{"thumb_path": "/nonexistent/thumb.jpg"}"""; + var model = new ClipboardItem + { + Content = @"C:\nonexistent\image.png", + Type = ClipboardContentType.Image, + Metadata = metadata + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ImagePath; + + // thumb_path doesn't exist, so falls back to placeholder + Assert.NotEmpty(path); + } + + [Fact] + public void ImagePath_ForImageItemWithExistingContentFile_ReturnsContentPath() + { + var tempFile = Path.GetTempFileName(); + try + { + var model = new ClipboardItem + { + Content = tempFile, + Type = ClipboardContentType.Image, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ImagePath; + + Assert.Equal(tempFile, path); + } + finally + { + File.Delete(tempFile); + } + } +} + +public sealed class ClipboardItemViewModelThumbnailTests +{ + [Fact] + public void ThumbnailPath_ForVideoItemWithNoMetadata_ReturnsVideoPlaceholder() + { + var model = new ClipboardItem + { + Content = @"C:\nonexistent\video.mp4", + Type = ClipboardContentType.Video, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ThumbnailPath; + + Assert.Contains("video", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ThumbnailPath_ForAudioItemWithNoMetadata_ReturnsAudioPlaceholder() + { + var model = new ClipboardItem + { + Content = @"C:\nonexistent\audio.mp3", + Type = ClipboardContentType.Audio, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ThumbnailPath; + + Assert.Contains("audio", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ThumbnailPath_SecondAccess_UsesCachedValue() + { + var model = new ClipboardItem + { + Content = @"C:\nonexistent\video.mp4", + Type = ClipboardContentType.Video, + Metadata = null + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var first = vm.ThumbnailPath; + var second = vm.ThumbnailPath; + + Assert.Equal(first, second); + } + + [Fact] + public void ThumbnailPath_WithThumbPathMetadataButMissingFile_ReturnsPlaceholder() + { + var metadata = """{"thumb_path": "/nonexistent/path/thumb.jpg"}"""; + var model = new ClipboardItem + { + Content = @"C:\nonexistent\video.mp4", + Type = ClipboardContentType.Video, + Metadata = metadata + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ThumbnailPath; + + // File doesn't exist, so falls back to placeholder + Assert.NotEmpty(path); + Assert.Contains("ms-appx", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ThumbnailPath_WithExistingThumbFile_ReturnsThumbnailPath() + { + var thumbFile = Path.GetTempFileName(); + try + { + var metadata = $$"""{"thumb_path": "{{thumbFile.Replace("\\", "\\\\", StringComparison.Ordinal)}}"}"""; + var model = new ClipboardItem + { + Content = @"C:\nonexistent\video.mp4", + Type = ClipboardContentType.Video, + Metadata = metadata + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ThumbnailPath; + + Assert.Equal(thumbFile, path); + } + finally + { + File.Delete(thumbFile); + } + } + + [Fact] + public void ThumbnailPath_WithMetadataNoThumbPathProperty_ReturnsPlaceholder() + { + var metadata = """{"duration": 120, "file_size": 50000}"""; + var model = new ClipboardItem + { + Content = @"C:\nonexistent\video.mp4", + Type = ClipboardContentType.Video, + Metadata = metadata + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ThumbnailPath; + + Assert.Contains("ms-appx", path, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ThumbnailPath_WithMalformedMetadata_ReturnsPlaceholder() + { + // Malformed JSON triggers JsonException catch in GetThumbnailPath, returns null, + // then GetThumbnailPathOrPlaceholder returns the placeholder. + var model = new ClipboardItem + { + Content = @"C:\nonexistent\video.mp4", + Type = ClipboardContentType.Video, + Metadata = "{ this is not valid json {{{" + }; + var vm = new ClipboardItemViewModel(model, _ => { }, (_, _) => { }, _ => { }); + + var path = vm.ThumbnailPath; + + Assert.NotEmpty(path); + Assert.Contains("ms-appx", path, StringComparison.OrdinalIgnoreCase); + } +} + +/// +/// Tests for exception-path coverage in GetThumbnailPath, GetImagePathOrThumbnail, +/// GetImageDimensions, and GetMediaDuration (malformed JSON metadata). +/// +public sealed class ClipboardItemViewModelMetadataExceptionTests +{ + private static ClipboardItemViewModel Create(ClipboardItem model) => + new(model, _ => { }, (_, _) => { }, _ => { }); + + [Fact] + public void ImagePath_WithMalformedJson_DoesNotThrow() + { + // Malformed JSON triggers JsonException in GetThumbnailPath (caught), + // falls through to Image placeholder in GetImagePathOrThumbnail. + var model = new ClipboardItem + { + Content = @"C:\nonexistent\img.png", + Type = ClipboardContentType.Image, + Metadata = "not valid json at all {{{" + }; + var vm = Create(model); + + var exception = Record.Exception(() => _ = vm.ImagePath); + + Assert.Null(exception); + } + + [Fact] + public void ImagePath_ForNonImageTypeWithNoValidContent_ReturnsEmpty() + { + // Link type with non-existent content → GetImagePathOrThumbnail returns string.Empty + var model = new ClipboardItem + { + Content = @"C:\this\does\not\exist.lnk", + Type = ClipboardContentType.Link, + Metadata = null + }; + var vm = Create(model); + + var path = vm.ImagePath; + + Assert.Equal(string.Empty, path); + } + + [Fact] + public void ImageDimensions_WithMalformedJson_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image, + Metadata = "not valid json {{{" + }; + var vm = Create(model); + + var dims = vm.ImageDimensions; + + Assert.Null(dims); + } + + [Fact] + public void MediaDuration_WithMalformedJson_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "video.mp4", + Type = ClipboardContentType.Video, + Metadata = "{ badJson" + }; + var vm = Create(model); + + var duration = vm.MediaDuration; + + Assert.Null(duration); + } + + [Fact] + public void FileSize_WithMalformedJson_ReturnsNull() + { + var model = new ClipboardItem + { + Content = "file.txt", + Type = ClipboardContentType.File, + Metadata = "INVALID" + }; + var vm = Create(model); + + var size = vm.FileSize; + + Assert.Null(size); + } +} + +/// +/// Tests for missing branch coverage in GetImageDimensions, GetMediaDuration, +/// GetImagePathOrThumbnail, and GetThumbnailPathOrPlaceholder. +/// +public sealed class ClipboardItemViewModelMissingBranchTests +{ + private static ClipboardItemViewModel Create(ClipboardItem model) => + new(model, _ => { }, (_, _) => { }, _ => { }); + + [Fact] + public void ImageDimensions_WithOnlyWidthProperty_ReturnsNull() + { + // TryGetProperty("height") fails → falls through the if → returns null + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image, + Metadata = """{"width": 1920}""" + }; + var vm = Create(model); + + Assert.Null(vm.ImageDimensions); + } + + [Fact] + public void ImageDimensions_WithOnlyHeightProperty_ReturnsNull() + { + // TryGetProperty("width") fails → returns null + var model = new ClipboardItem + { + Content = "img.png", + Type = ClipboardContentType.Image, + Metadata = """{"height": 1080}""" + }; + var vm = Create(model); + + Assert.Null(vm.ImageDimensions); + } + + [Fact] + public void MediaDuration_WithMetadataButNoDurationKey_ReturnsNull() + { + // Metadata is valid JSON but has no "duration" property → TryGetProperty returns false → null + var model = new ClipboardItem + { + Content = "video.mp4", + Type = ClipboardContentType.Video, + Metadata = """{"file_size": 50000}""" + }; + var vm = Create(model); + + Assert.Null(vm.MediaDuration); + } + + [Fact] + public void GetThumbnailPathOrPlaceholder_ForAudioType_ReturnsAudioPlaceholder() + { + // Covers the Audio branch in the switch expression + var model = new ClipboardItem + { + Content = "audio.mp3", + Type = ClipboardContentType.Audio, + Metadata = null + }; + var vm = Create(model); + + // ThumbnailPath goes through GetThumbnailPathOrPlaceholder + Assert.Contains("audio", vm.ThumbnailPath, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetImagePathOrThumbnail_ForNonImageNonExistentContent_ReturnsEmpty() + { + // Type != Image, Content path doesn't exist → returns string.Empty + var model = new ClipboardItem + { + Content = @"C:\does\not\exist\file.link", + Type = ClipboardContentType.Link, + Metadata = null + }; + var vm = Create(model); + + Assert.Equal(string.Empty, vm.ImagePath); + } +} diff --git a/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs b/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs index 2282591..ca9cf83 100644 --- a/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs +++ b/Tests/CopyPaste.UI.Tests/LocalizationServiceTests.cs @@ -170,6 +170,44 @@ public void Constructor_LoadsKeysSuccessfully() Assert.NotEqual("[clipboard.contextMenu.delete]", clipboardKey); } + [Fact] + public void Constructor_WithRegionalBaseLang_ResolvesToRegionalVariant() + { + // "es" has no exact match in available languages, but language-config.json + // maps regional "es" → "es-CL", so it should resolve to "es-CL". + _service = new LocalizationService("es"); + + Assert.Equal("es-CL", _service.CurrentLanguage); + } + + [Fact] + public void Constructor_WithEsArg_FallsBackToEsCL() + { + // "es-AR" is not a supported language; base language "es" maps to "es-CL". + _service = new LocalizationService("es-AR"); + + Assert.Equal("es-CL", _service.CurrentLanguage); + } + + [Fact] + public void Constructor_WithUnknownPreferredAndUnknownOsCulture_FallsBackToEnUs() + { + // Force OS culture to something not in available languages and without a regional mapping. + // This exercises GetGlobalFallback() → returns "en-US". + var original = System.Threading.Thread.CurrentThread.CurrentCulture; + try + { + System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("zh-CN"); + // "zh-XX" not in availableLanguages, no regional.zh mapping → warn → OS "zh-CN" also not found, no regional.zh → GetGlobalFallback() + _service = new LocalizationService("zh-XX"); + Assert.Equal("en-US", _service.CurrentLanguage); + } + finally + { + System.Threading.Thread.CurrentThread.CurrentCulture = original; + } + } + #endregion public void Dispose() diff --git a/Tests/CopyPaste.UI.Tests/SettingsLoadSaveTests.cs b/Tests/CopyPaste.UI.Tests/SettingsLoadSaveTests.cs new file mode 100644 index 0000000..3d2efd7 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/SettingsLoadSaveTests.cs @@ -0,0 +1,268 @@ +using CopyPaste.Core; +using CopyPaste.UI.Themes; +using System; +using System.IO; +using System.Reflection; +using System.Threading; +using Xunit; + +namespace CopyPaste.UI.Tests; + +// Single semaphore ensures only one settings test holds the redirected _appDataPath at a time. +internal static class SettingsTestSync +{ + internal static readonly SemaphoreSlim Gate = new(1, 1); + internal static readonly FieldInfo AppDataPathField = + typeof(StorageConfig).GetField("_appDataPath", BindingFlags.NonPublic | BindingFlags.Static)!; +} + +[Collection("SettingsSerialTests")] +public sealed class CompactSettingsLoadSaveTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _originalPath; + + public CompactSettingsLoadSaveTests() + { + SettingsTestSync.Gate.Wait(); + _tempDir = Path.Combine(Path.GetTempPath(), "CopyPasteTests_Compact", Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path.Combine(_tempDir, "config")); + _originalPath = (SettingsTestSync.AppDataPathField.GetValue(null) as string)!; + SettingsTestSync.AppDataPathField.SetValue(null, _tempDir); + } + + public void Dispose() + { + SettingsTestSync.AppDataPathField.SetValue(null, _originalPath); + try { Directory.Delete(_tempDir, recursive: true); } catch { } + SettingsTestSync.Gate.Release(); + } + + [Fact] + public void Load_WhenNoFileExists_ReturnsDefaultValues() + { + var settings = CompactSettings.Load(); + + Assert.NotNull(settings); + Assert.Equal(368, settings.PopupWidth); + } + + [Fact] + public void Load_WhenValidFileExists_ReturnsSavedValues() + { + CompactSettings.Save(new CompactSettings { PopupWidth = 500, PopupHeight = 600 }); + + var loaded = CompactSettings.Load(); + + Assert.Equal(500, loaded.PopupWidth); + Assert.Equal(600, loaded.PopupHeight); + } + + [Fact] + public void Load_WhenFileHasInvalidJson_ReturnsDefaultValues() + { + var configFile = Path.Combine(_tempDir, "config", "CompactTheme.json"); + Directory.CreateDirectory(Path.GetDirectoryName(configFile)!); + File.WriteAllText(configFile, "{ this is: not valid json !!"); + + var settings = CompactSettings.Load(); + + Assert.NotNull(settings); + Assert.Equal(368, settings.PopupWidth); + } + + [Fact] + public void Save_ReturnsTrueOnSuccess() + { + var result = CompactSettings.Save(new CompactSettings()); + + Assert.True(result); + } + + [Fact] + public void Save_PersistsBooleanProperties() + { + CompactSettings.Save(new CompactSettings + { + PinWindow = true, + ScrollToTopOnPaste = false, + HideOnDeactivate = false, + ResetSearchOnShow = false + }); + + var loaded = CompactSettings.Load(); + + Assert.True(loaded.PinWindow); + Assert.False(loaded.ScrollToTopOnPaste); + } + + [Fact] + public void RoundTrip_PreservesAllWrittenValues() + { + CompactSettings.Save(new CompactSettings { PopupWidth = 380, PopupHeight = 520, CardMinLines = 1, CardMaxLines = 10 }); + + var loaded = CompactSettings.Load(); + + Assert.Equal(380, loaded.PopupWidth); + Assert.Equal(1, loaded.CardMinLines); + Assert.Equal(10, loaded.CardMaxLines); + } + + [Fact] + public void Save_CreatesConfigDir_WhenMissing() + { + Directory.Delete(Path.Combine(_tempDir, "config"), recursive: true); + + var result = CompactSettings.Save(new CompactSettings()); + + Assert.True(result); + } + + [Fact] + public void Load_WhenFileContainsJsonNull_ReturnsDefaults() + { + // JsonSerializer.Deserialize returns null for "null" JSON → Load falls back to defaults + var configFile = Path.Combine(_tempDir, "config", "CompactTheme.json"); + File.WriteAllText(configFile, "null"); + + var settings = CompactSettings.Load(); + + Assert.NotNull(settings); + Assert.Equal(368, settings.PopupWidth); + } + + [Fact] + public void Save_ReturnsFalse_WhenFileLocked() + { + // Create a locked/read-only file → WriteAllText throws; Save returns false + var configFile = Path.Combine(_tempDir, "config", "CompactTheme.json"); + File.WriteAllText(configFile, "{}"); + // Hold an exclusive read lock on the file + using var lockHandle = new FileStream(configFile, FileMode.Open, FileAccess.Read, FileShare.None); + + var result = CompactSettings.Save(new CompactSettings()); + + Assert.False(result); + } +} + +[Collection("SettingsSerialTests")] +public sealed class DefaultThemeSettingsLoadSaveTests : IDisposable +{ + private readonly string _tempDir; + private readonly string _originalPath; + + public DefaultThemeSettingsLoadSaveTests() + { + SettingsTestSync.Gate.Wait(); + _tempDir = Path.Combine(Path.GetTempPath(), "CopyPasteTests_Default", Guid.NewGuid().ToString()); + Directory.CreateDirectory(Path.Combine(_tempDir, "config")); + _originalPath = (SettingsTestSync.AppDataPathField.GetValue(null) as string)!; + SettingsTestSync.AppDataPathField.SetValue(null, _tempDir); + } + + public void Dispose() + { + SettingsTestSync.AppDataPathField.SetValue(null, _originalPath); + try { Directory.Delete(_tempDir, recursive: true); } catch { } + SettingsTestSync.Gate.Release(); + } + + [Fact] + public void Load_WhenNoFileExists_ReturnsDefaultValues() + { + var settings = DefaultThemeSettings.Load(); + + Assert.NotNull(settings); + Assert.Equal(400, settings.WindowWidth); + } + + [Fact] + public void Load_WhenValidFileExists_ReturnsSavedValues() + { + DefaultThemeSettings.Save(new DefaultThemeSettings { WindowWidth = 600, CardMaxLines = 12 }); + + var loaded = DefaultThemeSettings.Load(); + + Assert.Equal(600, loaded.WindowWidth); + Assert.Equal(12, loaded.CardMaxLines); + } + + [Fact] + public void Load_WhenFileHasInvalidJson_ReturnsDefaultValues() + { + var configFile = Path.Combine(_tempDir, "config", "DefaultTheme.json"); + Directory.CreateDirectory(Path.GetDirectoryName(configFile)!); + File.WriteAllText(configFile, "INVALID {{{ JSON"); + + var settings = DefaultThemeSettings.Load(); + + Assert.NotNull(settings); + Assert.Equal(400, settings.WindowWidth); + } + + [Fact] + public void Save_ReturnsTrueOnSuccess() + { + var result = DefaultThemeSettings.Save(new DefaultThemeSettings()); + + Assert.True(result); + } + + [Fact] + public void Save_PersistsMarginValues() + { + DefaultThemeSettings.Save(new DefaultThemeSettings { WindowMarginTop = 4, WindowMarginBottom = 20 }); + + var loaded = DefaultThemeSettings.Load(); + + Assert.Equal(4, loaded.WindowMarginTop); + Assert.Equal(20, loaded.WindowMarginBottom); + } + + [Fact] + public void RoundTrip_PreservesAllWrittenValues() + { + DefaultThemeSettings.Save(new DefaultThemeSettings { WindowWidth = 500, CardMinLines = 2, CardMaxLines = 10, PinWindow = true }); + + var loaded = DefaultThemeSettings.Load(); + + Assert.Equal(500, loaded.WindowWidth); + Assert.Equal(2, loaded.CardMinLines); + Assert.True(loaded.PinWindow); + } + + [Fact] + public void Save_CreatesConfigDir_WhenMissing() + { + Directory.Delete(Path.Combine(_tempDir, "config"), recursive: true); + + var result = DefaultThemeSettings.Save(new DefaultThemeSettings()); + + Assert.True(result); + } + + [Fact] + public void Load_WhenFileContainsJsonNull_ReturnsDefaults() + { + var configFile = Path.Combine(_tempDir, "config", "DefaultTheme.json"); + File.WriteAllText(configFile, "null"); + + var settings = DefaultThemeSettings.Load(); + + Assert.NotNull(settings); + Assert.Equal(400, settings.WindowWidth); + } + + [Fact] + public void Save_ReturnsFalse_WhenFileLocked() + { + var configFile = Path.Combine(_tempDir, "config", "DefaultTheme.json"); + File.WriteAllText(configFile, "{}"); + using var lockHandle = new FileStream(configFile, FileMode.Open, FileAccess.Read, FileShare.None); + + var result = DefaultThemeSettings.Save(new DefaultThemeSettings()); + + Assert.False(result); + } +} diff --git a/Tests/CopyPaste.UI.Tests/ViewModelMiscCommandTests.cs b/Tests/CopyPaste.UI.Tests/ViewModelMiscCommandTests.cs new file mode 100644 index 0000000..9b5b739 --- /dev/null +++ b/Tests/CopyPaste.UI.Tests/ViewModelMiscCommandTests.cs @@ -0,0 +1,360 @@ +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 ViewModelClearSearchCommandTests +{ + private static DefaultThemeViewModel CreateViewModel(IClipboardService? service = null) + { + service ??= new ClearSearchEmptyStub(); + return new DefaultThemeViewModel(service, new MyMConfig(), new DefaultThemeSettings()); + } + + [Fact] + public void ClearSearchCommand_ClearsNonEmptySearchQuery() + { + var vm = CreateViewModel(); + vm.ToggleColorFilter(CardColor.Red); + vm.SearchQuery = "clipboard"; + + vm.ClearSearchCommand.Execute(null); + + Assert.Equal(string.Empty, vm.SearchQuery); + } + + [Fact] + public void ClearSearchCommand_WhenAlreadyEmpty_DoesNotThrow() + { + var vm = CreateViewModel(); + vm.SearchQuery = string.Empty; + + var exception = Record.Exception(() => vm.ClearSearchCommand.Execute(null)); + + Assert.Null(exception); + Assert.Equal(string.Empty, vm.SearchQuery); + } + + [Fact] + public void ClearSearchCommand_FiresPropertyChangedForSearchQuery() + { + var vm = CreateViewModel(); + vm.SearchQuery = "find me"; + var changedProps = new List(); + vm.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.ClearSearchCommand.Execute(null); + + Assert.Contains(nameof(vm.SearchQuery), changedProps); + } + + [Fact] + public void ClearSearchCommand_WithWhitespaceQuery_ClearsToEmpty() + { + var vm = CreateViewModel(); + vm.SearchQuery = " "; + + vm.ClearSearchCommand.Execute(null); + + Assert.Equal(string.Empty, vm.SearchQuery); + } +} + +public sealed class ViewModelRefreshFileAvailabilityTests +{ + private static DefaultThemeViewModel CreateWithItems(List items) + { + var service = new FileAvailabilityItemsStub(items); + var config = new MyMConfig { PageSize = 50, MaxItemsBeforeCleanup = 100 }; + var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings()); + vm.ToggleColorFilter(CardColor.Red); // triggers initial load + return vm; + } + + [Fact] + public void RefreshFileAvailability_WithEmptyItems_DoesNotThrow() + { + var vm = CreateWithItems([]); + + var exception = Record.Exception(() => vm.RefreshFileAvailability()); + + Assert.Null(exception); + } + + [Fact] + public void RefreshFileAvailability_WithOnlyTextItems_DoesNotThrow() + { + var items = new List + { + new() { Content = "hello", Type = ClipboardContentType.Text }, + new() { Content = "world", Type = ClipboardContentType.Text }, + }; + var vm = CreateWithItems(items); + + var exception = Record.Exception(() => vm.RefreshFileAvailability()); + + Assert.Null(exception); + } + + [Fact] + public void RefreshFileAvailability_OnFileTypeItem_FiresFileWarningVisibilityPropertyChanged() + { + var fileItem = new ClipboardItem + { + Content = @"C:\this\does\not\exist\file.txt", + Type = ClipboardContentType.File + }; + var vm = CreateWithItems([fileItem]); + var fileItemVM = vm.Items.First(i => i.IsFileType); + var changedProps = new List(); + fileItemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshFileAvailability(); + + Assert.Contains(nameof(ClipboardItemViewModel.FileWarningVisibility), changedProps); + } + + [Fact] + public void RefreshFileAvailability_OnFolderTypeItem_FiresPropertyChanged() + { + var folderItem = new ClipboardItem + { + Content = @"C:\this\does\not\exist\", + Type = ClipboardContentType.Folder + }; + var vm = CreateWithItems([folderItem]); + var folderItemVM = vm.Items.First(i => i.IsFileType); + var changedProps = new List(); + folderItemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshFileAvailability(); + + Assert.NotEmpty(changedProps); + } + + [Fact] + public void RefreshFileAvailability_TextItemsAreSkipped_NoPropertyChangedFired() + { + var textItem = new ClipboardItem { Content = "plain text", Type = ClipboardContentType.Text }; + var vm = CreateWithItems([textItem]); + var textItemVM = vm.Items.First(); + var changedProps = new List(); + textItemVM.PropertyChanged += (_, e) => changedProps.Add(e.PropertyName); + + vm.RefreshFileAvailability(); + + Assert.Empty(changedProps); + } + + [Fact] + public void RefreshFileAvailability_MixedItems_OnlyFileTypesRefreshed() + { + var fileItem = new ClipboardItem + { + Content = @"C:\nonexistent.txt", + Type = ClipboardContentType.File + }; + var textItem = new ClipboardItem { Content = "text", Type = ClipboardContentType.Text }; + var vm = CreateWithItems([fileItem, textItem]); + + var fileItemVM = vm.Items.First(i => i.IsFileType); + var textItemVM = vm.Items.First(i => !i.IsFileType); + + var fileChangedProps = new List(); + var textChangedProps = new List(); + fileItemVM.PropertyChanged += (_, e) => fileChangedProps.Add(e.PropertyName); + textItemVM.PropertyChanged += (_, e) => textChangedProps.Add(e.PropertyName); + + vm.RefreshFileAvailability(); + + Assert.NotEmpty(fileChangedProps); + Assert.Empty(textChangedProps); + } +} + +public sealed class ViewModelCallbackTests +{ + private static (DefaultThemeViewModel vm, ViewModelCallbackStubService service) CreateVmWithItems( + List items) + { + var service = new ViewModelCallbackStubService(items); + var config = new MyMConfig { PageSize = 50, MaxItemsBeforeCleanup = 100 }; + var vm = new DefaultThemeViewModel(service, config, new DefaultThemeSettings()); + vm.ToggleColorFilter(CardColor.Red); // trigger load + return (vm, service); + } + + [Fact] + public void IsWindowPinned_DefaultThemeViewModel_ReturnsFalse() + { + var service = new ViewModelCallbackStubService([]); + var vm = new DefaultThemeViewModel(service, new MyMConfig(), new DefaultThemeSettings()); + + Assert.False(vm.IsWindowPinned); + } + + [Fact] + public void DeleteCommand_OnLoadedItem_RemovesItemFromList() + { + var items = new List + { + new() { Content = "item1", Type = ClipboardContentType.Text }, + new() { Content = "item2", Type = ClipboardContentType.Text } + }; + var (vm, _) = CreateVmWithItems(items); + var toDelete = vm.Items[0]; + + toDelete.DeleteCommand.Execute(null); + + Assert.Single(vm.Items); + } + + [Fact] + public void DeleteCommand_OnLoadedItem_CallsServiceRemove() + { + var item = new ClipboardItem { Content = "test", Type = ClipboardContentType.Text }; + var (vm, service) = CreateVmWithItems([item]); + var toDelete = vm.Items[0]; + + toDelete.DeleteCommand.Execute(null); + + Assert.Contains(item.Id, service.RemovedIds); + } + + [Fact] + public void DeleteCommand_LastItem_SetsIsEmptyTrue() + { + var item = new ClipboardItem { Content = "only", Type = ClipboardContentType.Text }; + var (vm, _) = CreateVmWithItems([item]); + var toDelete = vm.Items[0]; + + toDelete.DeleteCommand.Execute(null); + + Assert.True(vm.IsEmpty); + } + + [Fact] + public void TogglePinCommand_OnLoadedItem_CallsServiceUpdatePin() + { + var item = new ClipboardItem { Content = "pinme", Type = ClipboardContentType.Text, IsPinned = false }; + var (vm, service) = CreateVmWithItems([item]); + var toPin = vm.Items[0]; + + toPin.TogglePinCommand.Execute(null); + + Assert.Contains(item.Id, service.PinnedIds); + } + + [Fact] + public void EditCommand_OnLoadedItem_FiresOnEditRequestedEvent() + { + var item = new ClipboardItem { Content = "edit me", Type = ClipboardContentType.Text }; + var (vm, _) = CreateVmWithItems([item]); + var toEdit = vm.Items[0]; + ClipboardItemViewModel? receivedVM = null; + vm.OnEditRequested += (_, e) => receivedVM = e; + + toEdit.EditCommand.Execute(null); + + Assert.NotNull(receivedVM); + Assert.Equal(item.Id, receivedVM.Model.Id); + } + + [Fact] + public void SelectedTabIndex_SetTo2_LoadsAllItemsWithoutFilter() + { + var items = new List + { + new() { Content = "pinned", Type = ClipboardContentType.Text, IsPinned = true }, + new() { Content = "unpinned", Type = ClipboardContentType.Text, IsPinned = false } + }; + var (vm, _) = CreateVmWithItems(items); + + // SelectedTabIndex=2 triggers CurrentPinnedFilter = null (all items) + var exception = Record.Exception(() => vm.SelectedTabIndex = 2); + + Assert.Null(exception); + } +} + +internal sealed class ViewModelCallbackStubService : IClipboardService +{ + private readonly List _items; + public List RemovedIds { get; } = []; + public List PinnedIds { get; } = []; + + public ViewModelCallbackStubService(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 RemoveItem(Guid id) => RemovedIds.Add(id); + public void UpdatePin(Guid id, bool isPinned) => PinnedIds.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(System.Collections.ObjectModel.Collection? files, ClipboardContentType type, string? source) { } + public IEnumerable GetHistory(int limit = 50, int skip = 0, string? query = null, bool? isPinned = null) => []; + public void UpdateLabelAndColor(Guid id, string? label, CardColor color) { } + public ClipboardItem? MarkItemUsed(Guid id) => null; + public void NotifyPasteInitiated(Guid itemId) { } +} + +internal sealed class ClearSearchEmptyStub : 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 FileAvailabilityItemsStub : IClipboardService +{ + private readonly List _items; + + public FileAvailabilityItemsStub(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 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 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) { } +}