From 3d9037f9291999351a7a4fde0a42ba219188290a Mon Sep 17 00:00:00 2001 From: rgdevment Date: Mon, 23 Feb 2026 18:16:20 -0300 Subject: [PATCH] feat: add unit tests for CleanupService, StorageConfig, BackupService, and ClipboardService background processing --- .../Themes/Compact/CompactWindow.xaml | 6 +- .../BackupServiceCoverageTests.cs | 318 ++++++++++++ .../CleanupServiceTests.cs | 45 +- .../ClipboardServiceBackgroundTests.cs | 456 ++++++++++++++++++ .../StorageConfigTests.cs | 20 + 5 files changed, 841 insertions(+), 4 deletions(-) create mode 100644 Tests/CopyPaste.Core.Tests/BackupServiceCoverageTests.cs create mode 100644 Tests/CopyPaste.Core.Tests/ClipboardServiceBackgroundTests.cs diff --git a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml index 645abbc..eddf50c 100644 --- a/CopyPaste.UI/Themes/Compact/CompactWindow.xaml +++ b/CopyPaste.UI/Themes/Compact/CompactWindow.xaml @@ -374,11 +374,11 @@ - + Visibility="{x:Bind LabelVisibility, Mode=OneWay}"/> + MaxLines="1" Visibility="{x:Bind DefaultHeaderVisibility, Mode=OneWay}"/> diff --git a/Tests/CopyPaste.Core.Tests/BackupServiceCoverageTests.cs b/Tests/CopyPaste.Core.Tests/BackupServiceCoverageTests.cs new file mode 100644 index 0000000..53c2c82 --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/BackupServiceCoverageTests.cs @@ -0,0 +1,318 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using Microsoft.Data.Sqlite; +using Xunit; + +namespace CopyPaste.Core.Tests; + +/// +/// Additional BackupService tests targeting uncovered paths: +/// RestoreBackup version > CurrentVersion, RollbackFromSnapshot, +/// RestoreDirectory with files, CopyDirectoryFlat with files. +/// +public sealed class BackupServiceCoverageTests : IDisposable +{ + private readonly string _basePath; + + public BackupServiceCoverageTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(_basePath); + StorageConfig.Initialize(); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } + } + + private static void CreateMinimalDatabase() + { + 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(); + } + + private static void InsertItem(bool isPinned = false) + { + using var connection = new SqliteConnection($"Data Source={StorageConfig.DatabasePath}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @"INSERT INTO ClipboardItems + (Id, Content, Type, CreatedAt, ModifiedAt, IsPinned, CardColor, PasteCount) + VALUES ($id, 'test', 0, datetime('now'), datetime('now'), $pinned, 0, 0)"; + cmd.Parameters.AddWithValue("$id", Guid.NewGuid().ToString()); + cmd.Parameters.AddWithValue("$pinned", isPinned ? 1 : 0); + cmd.ExecuteNonQuery(); + } + + // ------------------------------------------------------------------------- + // RestoreBackup — Version > CurrentVersion + // ------------------------------------------------------------------------- + + [Fact] + public void RestoreBackup_WithFutureVersion_ReturnsNull() + { + // Craft a backup with a version higher than CurrentVersion + using var stream = CreateBackupWithVersion(BackupService.CurrentVersion + 1); + + var result = BackupService.RestoreBackup(stream); + + Assert.Null(result); + } + + [Fact] + public void RestoreBackup_WithExactlyCurrentVersion_Succeeds() + { + CreateMinimalDatabase(); + using var stream = new MemoryStream(); + BackupService.CreateBackup(stream, "1.0.0"); + + SqliteConnection.ClearAllPools(); + stream.Position = 0; + + var result = BackupService.RestoreBackup(stream); + Assert.NotNull(result); + Assert.Equal(BackupService.CurrentVersion, result.Version); + } + + // ------------------------------------------------------------------------- + // CreateBackup with pinned items (HasPinnedItems = true) + // ------------------------------------------------------------------------- + + [Fact] + public void CreateBackup_WithPinnedItems_ManifestHasPinnedItems() + { + CreateMinimalDatabase(); + InsertItem(isPinned: true); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.True(manifest.HasPinnedItems); + } + + [Fact] + public void CreateBackup_WithItems_ManifestHasCorrectItemCount() + { + CreateMinimalDatabase(); + InsertItem(isPinned: false); + InsertItem(isPinned: false); + InsertItem(isPinned: true); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.Equal(3, manifest.ItemCount); + } + + // ------------------------------------------------------------------------- + // RestoreDirectory with files (covers TryDeleteFile + ExtractToFile paths) + // ------------------------------------------------------------------------- + + [Fact] + public void RestoreBackup_WithImagesInArchive_RestoresImagesDirectory() + { + CreateMinimalDatabase(); + + // Create image files in the images directory before backup + string imgFile1 = Path.Combine(StorageConfig.ImagesPath, "test1.png"); + string imgFile2 = Path.Combine(StorageConfig.ImagesPath, "test2.png"); + File.WriteAllBytes(imgFile1, CreateMinimalPngBytes()); + File.WriteAllBytes(imgFile2, CreateMinimalPngBytes()); + + using var backupStream = new MemoryStream(); + BackupService.CreateBackup(backupStream, "1.0.0"); + + // Delete images to verify they get restored + File.Delete(imgFile1); + File.Delete(imgFile2); + Assert.False(File.Exists(imgFile1)); + + SqliteConnection.ClearAllPools(); + backupStream.Position = 0; + var result = BackupService.RestoreBackup(backupStream); + + Assert.NotNull(result); + Assert.Equal(2, result.ImageCount); + Assert.True(File.Exists(imgFile1)); + Assert.True(File.Exists(imgFile2)); + } + + [Fact] + public void RestoreBackup_ExistingImagesDeleted_BeforeRestoring() + { + CreateMinimalDatabase(); + + // Create one image in backup + string imgFile = Path.Combine(StorageConfig.ImagesPath, "original.png"); + File.WriteAllBytes(imgFile, CreateMinimalPngBytes()); + + using var backupStream = new MemoryStream(); + BackupService.CreateBackup(backupStream, "1.0.0"); + + // Add a different file that should be cleaned during restore + string extraFile = Path.Combine(StorageConfig.ImagesPath, "extra.png"); + File.WriteAllBytes(extraFile, CreateMinimalPngBytes()); + + SqliteConnection.ClearAllPools(); + backupStream.Position = 0; + BackupService.RestoreBackup(backupStream); + + // The extra file that wasn't in backup should have been deleted (TryDeleteFile path) + Assert.False(File.Exists(extraFile)); + Assert.True(File.Exists(imgFile)); + } + + // ------------------------------------------------------------------------- + // CreateBackup with thumbnail images (covers AddDirectoryToArchive with files) + // ------------------------------------------------------------------------- + + [Fact] + public void CreateBackup_WithThumbnails_ManifestHasCorrectThumbnailCount() + { + // Add thumbnails + string thumb1 = Path.Combine(StorageConfig.ThumbnailsPath, "thumb1_t.png"); + string thumb2 = Path.Combine(StorageConfig.ThumbnailsPath, "thumb2_t.png"); + File.WriteAllBytes(thumb1, CreateMinimalPngBytes()); + File.WriteAllBytes(thumb2, CreateMinimalPngBytes()); + + using var output = new MemoryStream(); + var manifest = BackupService.CreateBackup(output, "1.0.0"); + + Assert.Equal(2, manifest.ThumbnailCount); + } + + // ------------------------------------------------------------------------- + // RestoreBackup — invalid/corrupt archive triggers rollback path + // ------------------------------------------------------------------------- + + [Fact] + public void RestoreBackup_WithCorruptStream_ReturnsNull() + { + using var invalid = new MemoryStream(new byte[] { 1, 2, 3, 4, 5 }); + + var result = BackupService.RestoreBackup(invalid); + + Assert.Null(result); + } + + [Fact] + public void RestoreBackup_StreamFailsOnSecondSeek_TriggersRollbackAndReturnsNull() + { + // Create a valid backup first + CreateMinimalDatabase(); + using var validBackupData = new MemoryStream(); + BackupService.CreateBackup(validBackupData, "1.0.0"); + byte[] bytes = validBackupData.ToArray(); + + SqliteConnection.ClearAllPools(); + + // Wrap in a stream that fails on the second seek (after manifest is read) + using var failingStream = new FailOnSecondSeekStream(bytes); + var result = BackupService.RestoreBackup(failingStream); + + // Restore should fail and return null (rollback triggered) + Assert.Null(result); + } + + // ------------------------------------------------------------------------- + // ValidateBackup — corrupt manifest entry + // ------------------------------------------------------------------------- + + [Fact] + public void ValidateBackup_WithCorruptManifest_ReturnsNull() + { + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("manifest.json"); + using var entryStream = entry.Open(); + using var writer = new System.IO.StreamWriter(entryStream); + writer.Write("{ invalid json :::"); // Corrupt JSON + } + + stream.Position = 0; + var result = BackupService.ValidateBackup(stream); + + Assert.Null(result); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static MemoryStream CreateBackupWithVersion(int version) + { + var manifest = new BackupManifest + { + Version = version, + AppVersion = "99.0.0", + CreatedAtUtc = DateTime.UtcNow, + MachineName = "TestMachine", + ItemCount = 0 + }; + + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var entry = archive.CreateEntry("manifest.json"); + using var entryStream = entry.Open(); + JsonSerializer.Serialize(entryStream, manifest, BackupManifestJsonContext.Default.BackupManifest); + } + + stream.Position = 0; + return stream; + } + + private static byte[] CreateMinimalPngBytes() + { + using var bitmap = new SkiaSharp.SKBitmap(2, 2); + bitmap.SetPixel(0, 0, SkiaSharp.SKColors.Red); + using var image = SkiaSharp.SKImage.FromBitmap(bitmap); + using var data = image.Encode(SkiaSharp.SKEncodedImageFormat.Png, 100); + return data.ToArray(); + } +} + +/// +/// A MemoryStream that throws IOException on the second Position = 0 assignment. +/// Used to simulate a restore failure after the manifest is successfully read, +/// triggering the RollbackFromSnapshot code path. +/// +internal sealed class FailOnSecondSeekStream : MemoryStream +{ + private int _positionSetCount; + + public FailOnSecondSeekStream(byte[] data) : base(data) { } + + public override long Position + { + get => base.Position; + set + { + if (value == 0) + { + _positionSetCount++; + if (_positionSetCount > 1) + throw new IOException("Simulated stream seek failure for testing RollbackFromSnapshot"); + } + base.Position = value; + } + } +} diff --git a/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs b/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs index d292abb..57e026d 100644 --- a/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs +++ b/Tests/CopyPaste.Core.Tests/CleanupServiceTests.cs @@ -146,6 +146,43 @@ public void RunCleanupIfNeeded_SameDay_DoesNotCleanupTwice() Assert.Equal(1, repo.ClearCalls); } + [Fact] + public void RunCleanupIfNeeded_DeletedItemsGreaterThanZero_CleansUpSuccessfully() + { + var repo = new StubClipboardRepository(deletedCount: 5); + using var service = new CleanupService(repo, () => 30, startTimer: false); + + service.RunCleanupIfNeeded(); + + Assert.Equal(1, repo.ClearCalls); + Assert.Equal(5, repo.LastDeletedCount); + } + + [Fact] + public void RunCleanupIfNeeded_DeletedItemsIsZero_DoesNotThrow() + { + var repo = new StubClipboardRepository(deletedCount: 0); + using var service = new CleanupService(repo, () => 14, startTimer: false); + + var ex = Record.Exception(() => service.RunCleanupIfNeeded()); + + Assert.Null(ex); + Assert.Equal(1, repo.ClearCalls); + } + + [Fact] + public void Constructor_WithStartTimer_TimerCallbackFiresEventually() + { + var repo = new StubClipboardRepository(); + using var service = new CleanupService(repo, () => 7, startTimer: true); + + // Timer fires immediately with dueTime = TimeSpan.Zero + System.Threading.Thread.Sleep(500); + + // Timer callback (b__8_0) should have invoked RunCleanupIfNeeded at least once + Assert.True(repo.ClearCalls >= 1); + } + #endregion private static string GetLastCleanupFilePath() @@ -171,14 +208,20 @@ public void Dispose() private sealed class StubClipboardRepository : IClipboardRepository { + private readonly int _deletedCount; + + public StubClipboardRepository(int deletedCount = 0) => _deletedCount = deletedCount; + public int ClearCalls { get; private set; } public int LastRetentionDays { get; private set; } + public int LastDeletedCount { get; private set; } public int ClearOldItems(int days, bool excludePinned = true) { ClearCalls++; LastRetentionDays = days; - return 0; + LastDeletedCount = _deletedCount; + return _deletedCount; } public void Delete(Guid id) => throw new NotImplementedException(); diff --git a/Tests/CopyPaste.Core.Tests/ClipboardServiceBackgroundTests.cs b/Tests/CopyPaste.Core.Tests/ClipboardServiceBackgroundTests.cs new file mode 100644 index 0000000..01114db --- /dev/null +++ b/Tests/CopyPaste.Core.Tests/ClipboardServiceBackgroundTests.cs @@ -0,0 +1,456 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Threading; +using SkiaSharp; +using Xunit; + +namespace CopyPaste.Core.Tests; + +/// +/// Tests that exercise ClipboardService background processing paths: +/// ProcessImageAssetsBackground, ProcessImageFileBackground, ParseExistingMetadata, DetectImageFormat, GenerateThumbnail. +/// Uses Thread.Sleep to allow async tasks to complete before assertions. +/// +public sealed class ClipboardServiceBackgroundTests : IDisposable +{ + private readonly string _basePath; + private readonly StubRepository _repository; + private readonly ClipboardService _service; + + public ClipboardServiceBackgroundTests() + { + _basePath = Path.Combine(Path.GetTempPath(), "CopyPasteTests", Guid.NewGuid().ToString()); + StorageConfig.SetBasePath(_basePath); + StorageConfig.Initialize(); + + _repository = new StubRepository(); + _service = new ClipboardService(_repository); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch { } + } + + // ------------------------------------------------------------------------- + // ProcessImageAssetsBackground (triggered by AddImage with valid DIB data) + // ------------------------------------------------------------------------- + + [Fact] + public void AddImage_WithValidDib_CompletesBackgroundProcessing() + { + byte[] dibData = CreateValidDibData(4, 4, 24); + + _service.AddImage(dibData, "TestApp"); + + // Background task needs time to process the image + Thread.Sleep(3000); + + // At least one update should be recorded (image path update OR thumbnail update) + Assert.NotEmpty(_repository.UpdatedItems); + } + + [Fact] + public void AddImage_WithValidDib_BackgroundSetsImagePath() + { + byte[] dibData = CreateValidDibData(4, 4, 24); + ClipboardItem? savedItem = null; + _service.OnItemAdded += item => savedItem = item; + + _service.AddImage(dibData, "TestApp"); + + Thread.Sleep(3000); + + Assert.NotNull(savedItem); + // After background processing, item content should be updated to a file path + var lastUpdate = _repository.UpdatedItems.Count > 0 + ? _repository.UpdatedItems[^1] + : savedItem; + Assert.NotNull(lastUpdate); + } + + [Fact] + public void AddImage_WithValidDib_FiresThumbnailReadyEvent() + { + byte[] dibData = CreateValidDibData(4, 4, 24); + ClipboardItem? thumbnailItem = null; + _service.OnThumbnailReady += item => thumbnailItem = item; + + _service.AddImage(dibData, "TestApp"); + + Thread.Sleep(3000); + + // OnThumbnailReady should fire after background processing + Assert.NotNull(thumbnailItem); + } + + [Fact] + public void AddImage_LargeValidDib_CompletesWithoutException() + { + // 16x16 pixel bitmap - larger to ensure thumbnail path is exercised + byte[] dibData = CreateValidDibData(16, 16, 24); + var ex = Record.Exception(() => + { + _service.AddImage(dibData, "TestApp"); + Thread.Sleep(3000); + }); + + Assert.Null(ex); + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddImage_WithValidDib_GcThresholdNotExceeded_DoesNotCrash() + { + // Small image - pixel data well below GC threshold + byte[] dibData = CreateValidDibData(2, 2, 24); + + _service.AddImage(dibData, "TestApp"); + Thread.Sleep(3000); + + Assert.Single(_repository.SavedItems); + } + + // ------------------------------------------------------------------------- + // ProcessImageFileBackground (triggered by AddFiles with Image type) + // ------------------------------------------------------------------------- + + [Fact] + public void AddFiles_WithImageFile_CompletesBackgroundProcessing() + { + string pngPath = CreateTempPng("img_test.png"); + + _service.AddFiles(new Collection { pngPath }, ClipboardContentType.Image, "Explorer"); + + Thread.Sleep(3000); + + // Item should have been saved + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddFiles_WithImageFile_ParsesExistingMetadata() + { + // The item is created with metadata (file_count, file_name, etc.) + // ProcessImageFileBackground calls ParseExistingMetadata on it + string pngPath = CreateTempPng("img_parse_test.png"); + + _service.AddFiles(new Collection { pngPath }, ClipboardContentType.Image, "Explorer"); + + Thread.Sleep(3000); + + // After background processing, metadata should be updated + Assert.Single(_repository.SavedItems); + Assert.NotNull(_repository.SavedItems[0].Metadata); + } + + [Fact] + public void AddFiles_WithImageFile_FiresThumbnailReadyEvent() + { + string pngPath = CreateTempPng("img_thumb_event.png"); + ClipboardItem? thumbnailItem = null; + _service.OnThumbnailReady += item => thumbnailItem = item; + + _service.AddFiles(new Collection { pngPath }, ClipboardContentType.Image, "Explorer"); + + Thread.Sleep(3000); + + Assert.NotNull(thumbnailItem); + } + + [Fact] + public void AddFiles_WithImageFile_BackgroundUpdatesMetadata() + { + string pngPath = CreateTempPng("img_meta.png"); + + _service.AddFiles(new Collection { pngPath }, ClipboardContentType.Image, "Explorer"); + + Thread.Sleep(3000); + + // After background processing, at least one update happened (to set thumb_path, etc.) + Assert.True(_repository.UpdatedItems.Count >= 1); + } + + // ------------------------------------------------------------------------- + // DetectImageFormat (used in ProcessMediaThumbnailBackground, but we can test + // the PNG/JPEG/WebP signature detection paths indirectly via file processing) + // ------------------------------------------------------------------------- + + [Fact] + public void AddFiles_WithPngImageFile_ProcessesSuccessfully() + { + string pngPath = CreateTempPng("detect_png.png"); + + _service.AddFiles(new Collection { pngPath }, ClipboardContentType.Image, "App"); + + Thread.Sleep(3000); + + Assert.NotEmpty(_repository.SavedItems); + } + + // ------------------------------------------------------------------------- + // ConvertDibToBmp branches — colorsUsed > 0 path + // ------------------------------------------------------------------------- + + [Fact] + public void AddImage_DibWithColorsUsed_ConvertsToBmp() + { + // Create DIB with colorsUsed > 0 at offset 32 (triggers the second branch in ConvertDibToBmp) + byte[] dibData = CreateDibWithColorsUsed(4, 4, 24, colorsUsed: 4); + + _service.AddImage(dibData, "TestApp"); + + Thread.Sleep(3000); + + // Item should be saved (the BMP conversion should succeed) + Assert.Single(_repository.SavedItems); + } + + // ------------------------------------------------------------------------- + // ProcessMediaThumbnailBackground (triggered by AddFiles with Video/Audio type) + // Covers: ParseExistingMetadata, ExtractMediaMetadata (exception path), + // ExtractAudioArtwork (attempt + null result), AppLogger calls, finally block + // ------------------------------------------------------------------------- + + [Fact] + public void AddFiles_WithVideoType_TriggersMediaThumbnailBackground() + { + string fakeVideo = Path.Combine(_basePath, "fake_video.mp4"); + File.WriteAllBytes(fakeVideo, CreateMinimalPngBytes()); + + _service.AddFiles(new Collection { fakeVideo }, ClipboardContentType.Video, "Player"); + + Thread.Sleep(3000); + + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddFiles_WithVideoType_FiresThumbnailReadyEvent() + { + string fakeVideo = Path.Combine(_basePath, "fake_video2.mp4"); + File.WriteAllBytes(fakeVideo, CreateMinimalPngBytes()); + + ClipboardItem? thumbnailItem = null; + _service.OnThumbnailReady += item => thumbnailItem = item; + + _service.AddFiles(new Collection { fakeVideo }, ClipboardContentType.Video, "Player"); + + Thread.Sleep(3000); + + // OnThumbnailReady fires from the finally block regardless of success/failure + Assert.NotNull(thumbnailItem); + } + + [Fact] + public void AddFiles_WithAudioType_TriggersMediaThumbnailBackground() + { + string fakeAudio = Path.Combine(_basePath, "fake_audio.mp3"); + File.WriteAllBytes(fakeAudio, CreateMinimalPngBytes()); + + _service.AddFiles(new Collection { fakeAudio }, ClipboardContentType.Audio, "Player"); + + Thread.Sleep(3000); + + Assert.Single(_repository.SavedItems); + } + + [Fact] + public void AddFiles_WithAudioType_FiresThumbnailReadyEvent() + { + string fakeAudio = Path.Combine(_basePath, "fake_audio2.mp3"); + File.WriteAllBytes(fakeAudio, CreateMinimalPngBytes()); + + ClipboardItem? thumbnailItem = null; + _service.OnThumbnailReady += item => thumbnailItem = item; + + _service.AddFiles(new Collection { fakeAudio }, ClipboardContentType.Audio, "Player"); + + Thread.Sleep(3000); + + Assert.NotNull(thumbnailItem); + } + + [Fact] + public void AddFiles_WithVideoType_UpdatesMetadataAfterProcessing() + { + string fakeVideo = Path.Combine(_basePath, "fake_video3.mp4"); + File.WriteAllBytes(fakeVideo, CreateMinimalPngBytes()); + + _service.AddFiles(new Collection { fakeVideo }, ClipboardContentType.Video, "Player"); + + Thread.Sleep(3000); + + // Background processing should update item metadata in finally block + Assert.True(_repository.UpdatedItems.Count >= 1); + } + + [Fact] + public void AddFiles_WithAudioType_UpdatesMetadataAfterProcessing() + { + string fakeAudio = Path.Combine(_basePath, "fake_audio3.mp3"); + File.WriteAllBytes(fakeAudio, CreateMinimalPngBytes()); + + _service.AddFiles(new Collection { fakeAudio }, ClipboardContentType.Audio, "Player"); + + Thread.Sleep(3000); + + Assert.True(_repository.UpdatedItems.Count >= 1); + } + + // ------------------------------------------------------------------------- + // Helper methods + // ------------------------------------------------------------------------- + + private string CreateTempPng(string fileName) + { + string path = Path.Combine(_basePath, fileName); + using var bitmap = new SKBitmap(4, 4); + for (int y = 0; y < 4; y++) + for (int x = 0; x < 4; x++) + bitmap.SetPixel(x, y, new SKColor(255, (byte)(x * 64), (byte)(y * 64))); + using var image = SKImage.FromBitmap(bitmap); + using var data = image.Encode(SKEncodedImageFormat.Png, 100); + File.WriteAllBytes(path, data.ToArray()); + return path; + } + + private static byte[] CreateMinimalPngBytes() + { + using var bitmap = new SKBitmap(2, 2); + bitmap.SetPixel(0, 0, SKColors.Red); + bitmap.SetPixel(1, 0, SKColors.Green); + bitmap.SetPixel(0, 1, SKColors.Blue); + bitmap.SetPixel(1, 1, SKColors.White); + using var image = SKImage.FromBitmap(bitmap); + using var pngData = image.Encode(SKEncodedImageFormat.Png, 100); + return pngData.ToArray(); + } + + private static byte[] CreateValidDibData(int width, int height, int bitCount, int compression = 0) + { + int headerSize = 40; + 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 (bitCount == 24) + { + // Fill with valid RGB data: alternating red-ish pixels + int byteOffset = headerSize + paletteSize; + for (int y = 0; y < height; y++) + for (int x = 0; x < width; x++) + { + int idx = byteOffset + y * rowSize + x * 3; + if (idx + 2 < data.Length) + { + data[idx] = 0; // Blue + data[idx + 1] = 100; // Green + data[idx + 2] = 200; // Red (BMP is BGR) + } + } + } + + 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; + } + + private static byte[] CreateDibWithColorsUsed(int width, int height, int bitCount, int colorsUsed) + { + int headerSize = 40; + int rowSize = ((width * bitCount + 31) / 32) * 4; + int pixelDataSize = rowSize * height; + // paletteSize calculated from colorsUsed (second branch in ConvertDibToBmp) + int paletteSize = colorsUsed * 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(0).CopyTo(data, 16); // compression = 0 + BitConverter.GetBytes(pixelDataSize).CopyTo(data, 20); + BitConverter.GetBytes(colorsUsed).CopyTo(data, 32); // colorsUsed at offset 32 + + // Fill pixel data with some non-zero values + for (int i = headerSize + paletteSize; i < data.Length - 2; i += 3) + { + data[i] = 50; + data[i + 1] = 100; + data[i + 2] = 150; + } + + return data; + } + + private sealed class StubRepository : IClipboardRepository + { + public List SavedItems { get; } = []; + public List UpdatedItems { get; } = []; + public List DeletedIds { get; } = []; + public Dictionary ItemsById { get; } = []; + public Dictionary ItemsByHash { get; } = []; + + public void Save(ClipboardItem item) + { + lock (SavedItems) SavedItems.Add(item); + lock (ItemsById) ItemsById[item.Id] = item; + } + + public void Update(ClipboardItem item) + { + lock (UpdatedItems) UpdatedItems.Add(item); + } + + public ClipboardItem? GetById(Guid id) { lock (ItemsById) return ItemsById.GetValueOrDefault(id); } + + public ClipboardItem? FindByContentAndType(string content, ClipboardContentType type) => null; + + public ClipboardItem? FindByContentHash(string contentHash) + { + lock (ItemsByHash) return ItemsByHash.GetValueOrDefault(contentHash); + } + + public ClipboardItem? GetLatest() => null; + public IEnumerable GetAll() => []; + public void Delete(Guid id) { lock (DeletedIds) 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) => []; + } +} diff --git a/Tests/CopyPaste.Core.Tests/StorageConfigTests.cs b/Tests/CopyPaste.Core.Tests/StorageConfigTests.cs index a8387b6..f50b4f4 100644 --- a/Tests/CopyPaste.Core.Tests/StorageConfigTests.cs +++ b/Tests/CopyPaste.Core.Tests/StorageConfigTests.cs @@ -252,6 +252,26 @@ public void SetBasePath_UpdatesAllPaths() Assert.Contains(newBasePath, StorageConfig.ThumbnailsPath, StringComparison.Ordinal); } + [Fact] + public void ThemesPath_IsCorrect() + { + var expected = Path.Combine(_basePath, "themes"); + Assert.Equal(expected, StorageConfig.ThemesPath); + } + + [Fact] + public void ConfigPath_IsCorrect() + { + var expected = Path.Combine(_basePath, "config"); + Assert.Equal(expected, StorageConfig.ConfigPath); + } + + [Fact] + public void Initialize_CreatesConfigDirectory() + { + Assert.True(Directory.Exists(StorageConfig.ConfigPath)); + } + #endregion public void Dispose()