using Explorer.Analysis; using Explorer.Application; using Explorer.Domain; using Explorer.Presentation.ViewModels; using Explorer.Storage.Sqlite; using Microsoft.Extensions.Logging.Abstractions; namespace Explorer.Analysis.Tests; public class AnalysisTests { [Fact] public async Task Largest_dirs_files_and_types() { var db = Path.Combine(Path.GetTempPath(), "ew-an", Guid.NewGuid().ToString("N"), "index.db"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var source = new Source { StableKey = "k", DisplayName = "Media", Kind = SourceKind.NtfsLocal, LastRootPath = @"C:\m", Status = SourceStatus.Online }; source.Id = await store.Sources.UpsertAsync(source); var root = new IndexEntry { SourceId = source.Id, Name = "m", NameNorm = "m", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow, AggregateSize = 300 }; root.Id = await store.Entries.UpsertAsync(root); var movies = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "Movies", NameNorm = "movies", IsDirectory = true, PathRel = "Movies", LastSeenUtc = DateTimeOffset.UtcNow, AggregateSize = 200 }; movies.Id = await store.Entries.UpsertAsync(movies); await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, ParentId = movies.Id, Name = "a.mkv", NameNorm = "a.mkv", Extension = "mkv", SizeBytes = 200, PathRel = @"Movies\a.mkv", LastSeenUtc = DateTimeOffset.UtcNow }); await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "b.txt", NameNorm = "b.txt", Extension = "txt", SizeBytes = 100, PathRel = "b.txt", LastSeenUtc = DateTimeOffset.UtcNow }); var analysis = new AnalysisService(store); var dirs = await analysis.LargestDirectoriesAsync(source.Id, root.Id); Assert.Equal("Movies", dirs[0].Name); var files = await analysis.LargestFilesAsync(source.Id, null); Assert.Equal("a.mkv", files[0].Name); var types = await analysis.UsageByExtensionAsync(source.Id, null); Assert.Contains(types, t => t.Extension == "mkv" && t.TotalSize == 200); var cats = await analysis.UsageByCategoryAsync(source.Id, null); Assert.Contains(cats, c => c.Category == "Video" && c.TotalSize == 200); var drill = await analysis.DrilldownAsync(root.Id); Assert.Contains(drill, e => e.Name == "Movies"); Assert.Equal("Movies", dirs[0].PathRel); var global = await analysis.LargestDirectoriesAsync(source.Id, null); Assert.Contains(global, d => d.PathRel == "Movies"); Assert.DoesNotContain(global, d => d.ParentId is null); var first = await analysis.GetIndexStampAsync(); await store.Sources.UpdateIndexedAsync(source.Id, DateTimeOffset.UtcNow, source.ScanGeneration + 1); var second = await analysis.GetIndexStampAsync(); Assert.NotEqual(first, second); } [Fact] public void Sibling_size_bars_scale_to_the_largest_child() { var siblings = new[] { new StorageNodeViewModel { Name = "A", FullPath = @"C:\A", PathRel = "A", SourceId = 1, Size = 80 }, new StorageNodeViewModel { Name = "B", FullPath = @"C:\B", PathRel = "B", SourceId = 1, Size = 40 }, new StorageNodeViewModel { Name = "C", FullPath = @"C:\C", PathRel = "C", SourceId = 1, Size = 0 } }; AnalysisViewModel.ApplySiblingFractions(siblings); Assert.Equal(1, siblings[0].Fraction); Assert.Equal(0.5, siblings[1].Fraction); Assert.Equal(0, siblings[2].Fraction); } [Fact] public async Task Hash_worker_skips_online_only_without_opening() { var db = Path.Combine(Path.GetTempPath(), "ew-hash", Guid.NewGuid().ToString("N"), "index.db"); var rootPath = Path.Combine(Path.GetTempPath(), "ew-hash-fs", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(rootPath); File.WriteAllBytes(Path.Combine(rootPath, "local.bin"), new byte[64]); var missingOnline = Path.Combine(rootPath, "online-only.bin"); await using var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var source = new Source { StableKey = "h", DisplayName = "H", Kind = SourceKind.NtfsLocal, LastRootPath = rootPath, Status = SourceStatus.Online }; source.Id = await store.Sources.UpsertAsync(source); var root = new IndexEntry { SourceId = source.Id, Name = "H", NameNorm = "h", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow }; root.Id = await store.Entries.UpsertAsync(root); await store.Entries.UpsertAsync(new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "local.bin", NameNorm = "local.bin", SizeBytes = 64, PathRel = "local.bin", LastSeenUtc = DateTimeOffset.UtcNow }); var online = new IndexEntry { SourceId = source.Id, ParentId = root.Id, Name = "online-only.bin", NameNorm = "online-only.bin", SizeBytes = 64, PathRel = "online-only.bin", LastSeenUtc = DateTimeOffset.UtcNow, Attributes = AttributeFlags.RecallOnDataAccess, CloudAvailability = CloudAvailability.OnlineOnly }; online.Id = await store.Entries.UpsertAsync(online); await store.Hashes.EnqueueSizeCollisionsAsync(source.Id); var worker = new DuplicateHashWorker( store, new HydrationGuard(new StorageProviderRegistry([], NullLogger.Instance)), NullLogger.Instance); worker.Resume(); await worker.ProcessPendingAsync(CancellationToken.None); var skipped = await store.Entries.GetByPathAsync(source.Id, "online-only.bin"); Assert.Equal(HashState.Skipped, skipped!.HashState); Assert.False(File.Exists(missingOnline)); var local = await store.Entries.GetByPathAsync(source.Id, "local.bin"); Assert.Equal(HashState.Partial, local!.HashState); } [Fact] public async Task Hardlinks_are_grouped_but_excluded_from_default_duplicate_list() { var (store, analysis, source) = await SeedHashedAsync(fileIdA: 42, fileIdB: 42); await using (store) { var raw = await store.Hashes.GetDuplicateGroupsAsync(null, null, 10); var group = Assert.Single(raw); Assert.True(group.SameFileId); var classified = await analysis.GetClassifiedDuplicatesAsync(); var item = Assert.Single(classified); Assert.Equal(DuplicateClass.Hardlink, item.Classification); Assert.True(DuplicateClassifier.IsHiddenByDefault(item.Classification)); Assert.Equal(source.Id, item.Group.Entries[0].SourceId); } } [Fact] public async Task Marked_intentional_relation_is_excluded_from_default_list() { var (store, analysis, _) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2); await using (store) { var before = Assert.Single(await analysis.GetClassifiedDuplicatesAsync()); Assert.Equal(DuplicateClass.Unknown, before.Classification); Assert.False(DuplicateClassifier.IsHiddenByDefault(before.Classification)); await analysis.MarkDuplicateGroupAsync(before.Group.Entries, FileRelationKind.IntentionalDuplicate); var after = Assert.Single(await analysis.GetClassifiedDuplicatesAsync()); Assert.Equal(DuplicateClass.Intentional, after.Classification); Assert.True(DuplicateClassifier.IsHiddenByDefault(after.Classification)); await analysis.MarkDuplicateGroupAsync(after.Group.Entries, FileRelationKind.AccidentalDuplicate); var accidental = Assert.Single(await analysis.GetClassifiedDuplicatesAsync()); Assert.Equal(DuplicateClass.Accidental, accidental.Classification); Assert.False(DuplicateClassifier.IsHiddenByDefault(accidental.Classification)); } } [Fact] public async Task Missing_files_are_tombstoned_when_loading_duplicates() { var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2); await using (store) { File.Delete(Path.Combine(source.LastRootPath!, "a.bin")); var classified = await analysis.GetClassifiedDuplicatesAsync(); Assert.Empty(classified); var gone = await store.Entries.GetByPathAsync(source.Id, "a.bin"); Assert.Equal(EntryStatus.Deleted, gone!.Status); } } [Fact] public async Task Remaining_copies_still_show_when_one_duplicate_is_gone() { var (store, analysis, source) = await SeedHashedAsync(fileIdA: 1, fileIdB: 2); await using (store) { File.WriteAllBytes(Path.Combine(source.LastRootPath!, "c.bin"), new byte[64]); var root = await store.Entries.GetByPathAsync(source.Id, ""); await store.Entries.UpsertAsync(Hashed("c.bin", 3, source.Id, root!.Id, Enumerable.Repeat((byte)7, 32).ToArray())); File.Delete(Path.Combine(source.LastRootPath!, "a.bin")); var classified = Assert.Single(await analysis.GetClassifiedDuplicatesAsync()); Assert.Equal(2, classified.Group.Entries.Count); Assert.DoesNotContain(classified.Group.Entries, e => e.PathRel == "a.bin"); } } private static async Task<(SqliteIndexStore Store, AnalysisService Analysis, Source Source)> SeedHashedAsync( long fileIdA, long fileIdB) { var db = Path.Combine(Path.GetTempPath(), "ew-dup", Guid.NewGuid().ToString("N"), "index.db"); var rootPath = Path.Combine(Path.GetTempPath(), "ew-dup-fs", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(rootPath); File.WriteAllBytes(Path.Combine(rootPath, "a.bin"), new byte[64]); File.WriteAllBytes(Path.Combine(rootPath, "b.bin"), new byte[64]); var store = new SqliteIndexStore(db, NullLogger.Instance); await store.OpenAsync(); var source = new Source { StableKey = "d", DisplayName = "D", Kind = SourceKind.NtfsLocal, LastRootPath = rootPath, Status = SourceStatus.Online }; source.Id = await store.Sources.UpsertAsync(source); var hash = Enumerable.Repeat((byte)7, 32).ToArray(); var root = new IndexEntry { SourceId = source.Id, Name = "d", NameNorm = "d", IsDirectory = true, PathRel = "", LastSeenUtc = DateTimeOffset.UtcNow }; root.Id = await store.Entries.UpsertAsync(root); await store.Entries.UpsertAsync(Hashed("a.bin", fileIdA, source.Id, root.Id, hash)); await store.Entries.UpsertAsync(Hashed("b.bin", fileIdB, source.Id, root.Id, hash)); return (store, new AnalysisService(store), source); } private static IndexEntry Hashed(string name, long fileId, long sourceId, long parentId, byte[] hash) => new() { SourceId = sourceId, ParentId = parentId, Name = name, NameNorm = name, SizeBytes = 64, PathRel = name, FileId = fileId, ContentHash = hash, HashState = HashState.Full, LastSeenUtc = DateTimeOffset.UtcNow }; }