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()