263 lines
12 KiB
C#
263 lines
12 KiB
C#
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<SqliteIndexStore>.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<SqliteIndexStore>.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<StorageProviderRegistry>.Instance)),
|
|
NullLogger<DuplicateHashWorker>.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<SqliteIndexStore>.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
|
|
};
|
|
}
|