Persist file categories on the index and classify archives and unknown types during idle maintenance.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,6 +39,8 @@ public class AnalysisTests
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class EntryClassificationServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Archive_children_override_zip_as_photos()
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, @"C:\media");
|
||||
var zip = await Upsert(store, new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "photos.zip",
|
||||
NameNorm = "photos.zip",
|
||||
Extension = "zip",
|
||||
PathRel = "photos.zip",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await Upsert(store, Child(source.Id, zip.Id, "a.jpg", "jpg"));
|
||||
await Upsert(store, Child(source.Id, zip.Id, "b.jpg", "jpg"));
|
||||
await Upsert(store, Child(source.Id, zip.Id, "c.jpg", "jpg"));
|
||||
|
||||
var service = Create(store, new MemoryEnumerator());
|
||||
service.Resume();
|
||||
Assert.True(await service.ProcessPendingAsync());
|
||||
var loaded = await store.Entries.GetAsync(zip.Id);
|
||||
Assert.Equal(FileCategory.Photos, loaded!.Category);
|
||||
Assert.Equal(CategorySources.ArchiveContents, loaded.CategorySource);
|
||||
Assert.False(await service.ProcessPendingAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signature_classifies_jpeg_without_extension()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-classify", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
var file = Path.Combine(root, "pic.bin");
|
||||
File.WriteAllBytes(file, [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01]);
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, root);
|
||||
var entry = await Upsert(store, new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "pic.bin",
|
||||
NameNorm = "pic.bin",
|
||||
Extension = "bin",
|
||||
PathRel = "pic.bin",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
Assert.Equal(FileCategory.Unknown, entry.Category);
|
||||
|
||||
var enumerator = new MemoryEnumerator();
|
||||
enumerator.Add(new FileSystemItem { FullPath = file, Name = "pic.bin" });
|
||||
var service = Create(store, enumerator);
|
||||
service.Resume();
|
||||
Assert.True(await service.ProcessPendingAsync());
|
||||
var loaded = await store.Entries.GetAsync(entry.Id);
|
||||
Assert.Equal(FileCategory.Photos, loaded!.Category);
|
||||
Assert.Equal(CategorySources.Mime, loaded.CategorySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Pause_skips_work()
|
||||
{
|
||||
await using var store = await OpenStore();
|
||||
var source = await AddSource(store, @"C:\media");
|
||||
var zip = await Upsert(store, new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "photos.zip",
|
||||
NameNorm = "photos.zip",
|
||||
Extension = "zip",
|
||||
PathRel = "photos.zip",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await Upsert(store, Child(source.Id, zip.Id, "a.jpg", "jpg"));
|
||||
var service = Create(store, new MemoryEnumerator());
|
||||
Assert.True(service.IsPaused);
|
||||
Assert.False(await service.ProcessPendingAsync());
|
||||
var loaded = await store.Entries.GetAsync(zip.Id);
|
||||
Assert.Equal(FileCategory.Archive, loaded!.Category);
|
||||
}
|
||||
|
||||
private static EntryClassificationService Create(IIndexStore store, IFileSystemEnumerator enumerator)
|
||||
=> new(
|
||||
store,
|
||||
enumerator,
|
||||
new NeverHydrate(),
|
||||
NullLogger<EntryClassificationService>.Instance);
|
||||
|
||||
private static async Task<SqliteIndexStore> OpenStore()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-classify", Guid.NewGuid().ToString("N"), "index.db");
|
||||
var store = new SqliteIndexStore(db, NullLogger<SqliteIndexStore>.Instance);
|
||||
await store.OpenAsync();
|
||||
return store;
|
||||
}
|
||||
|
||||
private static async Task<Source> AddSource(IIndexStore store, string root)
|
||||
{
|
||||
var source = new Source
|
||||
{
|
||||
StableKey = Guid.NewGuid().ToString("N"),
|
||||
Kind = SourceKind.NtfsLocal,
|
||||
DisplayName = "Test",
|
||||
LastRootPath = root,
|
||||
Status = SourceStatus.Online
|
||||
};
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static async Task<IndexEntry> Upsert(IIndexStore store, IndexEntry entry)
|
||||
{
|
||||
entry.Id = await store.Entries.UpsertAsync(entry);
|
||||
return (await store.Entries.GetAsync(entry.Id))!;
|
||||
}
|
||||
|
||||
private static IndexEntry Child(long sourceId, long parentId, string name, string ext)
|
||||
=> new()
|
||||
{
|
||||
SourceId = sourceId,
|
||||
ParentId = parentId,
|
||||
Name = name,
|
||||
NameNorm = name,
|
||||
Extension = ext,
|
||||
PathRel = name,
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private sealed class NeverHydrate : IHydrationGuard
|
||||
{
|
||||
public bool WouldHydrateOnRead(FileSystemItem item) => false;
|
||||
public bool WouldHydrateOnRead(int attributes, CloudAvailability? availability) => false;
|
||||
public Task<bool> WouldHydrateOnReadAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
|
||||
private sealed class MemoryEnumerator : IFileSystemEnumerator
|
||||
{
|
||||
private readonly Dictionary<string, FileSystemItem> _items = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Add(FileSystemItem item) => _items[item.FullPath] = item;
|
||||
|
||||
public IEnumerable<FileSystemItem> EnumerateChildren(string directoryPath) => [];
|
||||
public FileSystemItem? GetItem(string path)
|
||||
=> _items.TryGetValue(path, out var item) ? item : null;
|
||||
public IReadOnlyList<FileSystemItem> EnumerateChildrenSafe(string directoryPath, out string? error)
|
||||
{
|
||||
error = null;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,40 @@ public class ReorganizePlannerTests
|
||||
Assert.Null(fs.GetItem(@"C:\Pictures\photo.jpg"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefers_indexed_archive_contents_over_extension()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\Downloads")
|
||||
.File(@"C:\Downloads\photos.zip", 4, T0)
|
||||
.Dir(@"C:\Pictures")
|
||||
.Dir(@"C:\Archive");
|
||||
|
||||
var indexed = new Dictionary<string, IndexEntry>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["photos.zip"] = new IndexEntry
|
||||
{
|
||||
Name = "photos.zip",
|
||||
NameNorm = "photos.zip",
|
||||
PathRel = "photos.zip",
|
||||
Category = FileCategory.Photos,
|
||||
CategorySource = CategorySources.ArchiveContents,
|
||||
CategoryReason = "Archive contents are mostly photos."
|
||||
}
|
||||
};
|
||||
var plan = new ReorganizePlanner().Build(
|
||||
@"C:\Downloads",
|
||||
Map(),
|
||||
fs,
|
||||
_ => true,
|
||||
_ => false,
|
||||
now: T0,
|
||||
pathExists: _ => false,
|
||||
indexedByName: indexed);
|
||||
Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("photos.zip") && o.DestinationPath == @"C:\Pictures\photos.zip");
|
||||
Assert.DoesNotContain(plan.Operations, o => o.DestinationPath == @"C:\Archive\photos.zip");
|
||||
}
|
||||
|
||||
private static OperationPlan Build(
|
||||
IFileSystemEnumerator fs,
|
||||
Action<OrganizeDestinations>? configure = null,
|
||||
|
||||
@@ -484,6 +484,108 @@ public class FileClassifierTests
|
||||
=> FileClassifier.Classify(name, @"C:\Downloads\" + name, isDirectory, isDirectory ? AttributeFlags.Directory : 0, isRepoRoot).Category;
|
||||
}
|
||||
|
||||
public class EntryCategoryAssignerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyAutomatic_sets_extension_category_on_files()
|
||||
{
|
||||
var entry = new IndexEntry
|
||||
{
|
||||
Name = "clip.mkv",
|
||||
NameNorm = "clip.mkv",
|
||||
PathRel = "clip.mkv",
|
||||
Extension = "mkv"
|
||||
};
|
||||
EntryCategoryAssigner.ApplyAutomatic(entry);
|
||||
Assert.Equal(FileCategory.Video, entry.Category);
|
||||
Assert.Equal(CategorySources.Extension, entry.CategorySource);
|
||||
Assert.True(entry.CategoryConfidence >= 80);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void User_category_is_not_overwritten()
|
||||
{
|
||||
var entry = new IndexEntry
|
||||
{
|
||||
Name = "clip.mkv",
|
||||
NameNorm = "clip.mkv",
|
||||
PathRel = "clip.mkv",
|
||||
Extension = "mkv"
|
||||
};
|
||||
EntryCategoryAssigner.ApplyUser(entry, FileCategory.Documents, "Manual");
|
||||
EntryCategoryAssigner.ApplyAutomatic(entry);
|
||||
Assert.Equal(FileCategory.Documents, entry.Category);
|
||||
Assert.Equal(CategorySources.User, entry.CategorySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Children_majority_sets_folder_or_archive_source()
|
||||
{
|
||||
var folder = new IndexEntry
|
||||
{
|
||||
Name = "Vacation",
|
||||
NameNorm = "vacation",
|
||||
PathRel = "Vacation",
|
||||
IsDirectory = true,
|
||||
Attributes = AttributeFlags.Directory
|
||||
};
|
||||
EntryCategoryAssigner.ApplyAutomatic(
|
||||
folder,
|
||||
childCategories: [FileCategory.Photos, FileCategory.Photos, FileCategory.Photos, FileCategory.Documents]);
|
||||
Assert.Equal(FileCategory.Photos, folder.Category);
|
||||
Assert.Equal(CategorySources.Children, folder.CategorySource);
|
||||
|
||||
var zip = new IndexEntry
|
||||
{
|
||||
Name = "photos.zip",
|
||||
NameNorm = "photos.zip",
|
||||
PathRel = "photos.zip",
|
||||
Extension = "zip"
|
||||
};
|
||||
EntryCategoryAssigner.ApplyAutomatic(
|
||||
zip,
|
||||
childCategories: [FileCategory.Photos, FileCategory.Photos, FileCategory.Photos]);
|
||||
Assert.Equal(FileCategory.Photos, zip.Category);
|
||||
Assert.Equal(CategorySources.ArchiveContents, zip.CategorySource);
|
||||
}
|
||||
}
|
||||
|
||||
public class OrganizeDestinationsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_category_aliases()
|
||||
{
|
||||
Assert.True(OrganizeDestinations.TryParse("photos", out var photos));
|
||||
Assert.Equal(FileCategory.Photos, photos);
|
||||
Assert.True(OrganizeDestinations.TryParse("zip", out var zip));
|
||||
Assert.Equal(FileCategory.Archive, zip);
|
||||
Assert.True(OrganizeDestinations.TryParse("unknown", out var unknown));
|
||||
Assert.Equal(FileCategory.Unknown, unknown);
|
||||
Assert.False(OrganizeDestinations.TryParse("not-a-category", out _));
|
||||
}
|
||||
}
|
||||
|
||||
public class FileSignatureClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Recognizes_common_headers()
|
||||
{
|
||||
Assert.Equal(FileCategory.Photos, Hit([0xFF, 0xD8, 0xFF, 0xE0]));
|
||||
Assert.Equal(FileCategory.Photos, Hit([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]));
|
||||
Assert.Equal(FileCategory.Documents, Hit([0x25, 0x50, 0x44, 0x46]));
|
||||
Assert.Equal(FileCategory.Archive, Hit([0x50, 0x4B, 0x03, 0x04]));
|
||||
Assert.Equal(FileCategory.Archive, Hit([0x37, 0x7A, 0xBC, 0xAF]));
|
||||
Assert.Equal(FileCategory.Installer, Hit([0x4D, 0x5A, 0x90, 0x00]));
|
||||
Assert.Equal(FileCategory.Audio, Hit("RIFF....WAVE"u8.ToArray()));
|
||||
Assert.Equal(FileCategory.Video, Hit("RIFF....AVI "u8.ToArray()));
|
||||
Assert.Null(FileSignatureClassifier.TryClassify([0x00, 0x01, 0x02, 0x03]));
|
||||
Assert.Null(FileSignatureClassifier.TryClassify([0xFF, 0xD8]));
|
||||
}
|
||||
|
||||
private static FileCategory Hit(byte[] header)
|
||||
=> FileSignatureClassifier.TryClassify(header)!.Category;
|
||||
}
|
||||
|
||||
public class GitChangeTests
|
||||
{
|
||||
[Fact]
|
||||
|
||||
@@ -149,6 +149,44 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
Assert.Empty(indexing.IdleScans);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_classifies_before_hashing()
|
||||
{
|
||||
var hash = new FakeHash { Pending = true };
|
||||
var indexing = new FakeIndexing();
|
||||
var classify = new FakeClassify { Work = true };
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), classify: classify);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(1, classify.Calls);
|
||||
Assert.True(hash.IsPaused);
|
||||
Assert.Contains("classifying", coordinator.Snapshot.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_hashes_when_classify_is_idle()
|
||||
{
|
||||
var hash = new FakeHash { Pending = true };
|
||||
var indexing = new FakeIndexing();
|
||||
var classify = new FakeClassify();
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.FromMinutes(20), classify: classify);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.Equal(1, classify.Calls);
|
||||
Assert.False(hash.IsPaused);
|
||||
Assert.Contains("hashing", coordinator.Snapshot.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activity_pauses_classify()
|
||||
{
|
||||
var hash = new FakeHash();
|
||||
var indexing = new FakeIndexing();
|
||||
var classify = new FakeClassify { Paused = false };
|
||||
var coordinator = Create(hash, indexing, idle: TimeSpan.Zero, classify: classify);
|
||||
await coordinator.TickAsync(CancellationToken.None);
|
||||
Assert.True(classify.IsPaused);
|
||||
Assert.Equal(0, classify.Calls);
|
||||
}
|
||||
|
||||
private static Source StaleLocal()
|
||||
=> new()
|
||||
{
|
||||
@@ -168,8 +206,9 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
bool enabled = true,
|
||||
bool ac = true,
|
||||
bool foreground = false,
|
||||
IReadOnlyList<Source>? sources = null)
|
||||
=> Create(hash, indexing, new FakeIdle(idle), enabled, ac, foreground, sources);
|
||||
IReadOnlyList<Source>? sources = null,
|
||||
FakeClassify? classify = null)
|
||||
=> Create(hash, indexing, new FakeIdle(idle), enabled, ac, foreground, sources, classify);
|
||||
|
||||
private static BackgroundMaintenanceCoordinator Create(
|
||||
FakeHash hash,
|
||||
@@ -178,7 +217,8 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
bool enabled = true,
|
||||
bool ac = true,
|
||||
bool foreground = false,
|
||||
IReadOnlyList<Source>? sources = null)
|
||||
IReadOnlyList<Source>? sources = null,
|
||||
FakeClassify? classify = null)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "ew-maint", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
@@ -199,7 +239,8 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
new FakeHistory(),
|
||||
new FakeStore(sources ?? []),
|
||||
new FakeVolumes(),
|
||||
NullLogger<BackgroundMaintenanceCoordinator>.Instance);
|
||||
NullLogger<BackgroundMaintenanceCoordinator>.Instance,
|
||||
classify: classify);
|
||||
}
|
||||
|
||||
private sealed class FakeIdle(TimeSpan idle) : IUserIdleMonitor
|
||||
@@ -233,14 +274,31 @@ public class BackgroundMaintenanceCoordinatorTests
|
||||
|
||||
private sealed class FakeHash : IIdleHashWork
|
||||
{
|
||||
public bool Pending { get; set; }
|
||||
public bool Paused { get; set; } = true;
|
||||
public bool IsPaused => Paused;
|
||||
public string? CurrentPath => null;
|
||||
public void Pause() => Paused = true;
|
||||
public void Resume() => Paused = false;
|
||||
public void BeginUserRequested() { }
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(false);
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(0L);
|
||||
public Task<bool> HasPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(Pending);
|
||||
public Task<long> CountPendingAsync(CancellationToken cancellationToken = default) => Task.FromResult(Pending ? 1L : 0L);
|
||||
}
|
||||
|
||||
private sealed class FakeClassify : IIdleClassifyWork
|
||||
{
|
||||
public bool Work { get; set; }
|
||||
public int Calls { get; private set; }
|
||||
public bool Paused { get; set; } = true;
|
||||
public bool IsPaused => Paused;
|
||||
public string? CurrentPath => null;
|
||||
public void Pause() => Paused = true;
|
||||
public void Resume() => Paused = false;
|
||||
public Task<bool> ProcessPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(Work);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeHistory : IHistoryMaintenance
|
||||
|
||||
@@ -36,6 +36,7 @@ public class CoreRegistrationTests
|
||||
Assert.NotNull(sp.GetService<IBackgroundMaintenance>());
|
||||
Assert.NotNull(sp.GetService<IUserIdleMonitor>());
|
||||
Assert.NotNull(sp.GetService<IPowerSourceMonitor>());
|
||||
Assert.NotNull(sp.GetService<IIdleClassifyWork>());
|
||||
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -44,4 +44,37 @@ public class SearchServiceTests
|
||||
Assert.Contains(both, e => e.Name == "Folder");
|
||||
Assert.Contains(both, e => e.Name == "notes.txt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Category_hint_and_filter()
|
||||
{
|
||||
var db = Path.Combine(Path.GetTempPath(), "ew-search", 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 = "d", Kind = SourceKind.NtfsLocal, LastRootPath = @"C:\d", Status = SourceStatus.Online };
|
||||
source.Id = await store.Sources.UpsertAsync(source);
|
||||
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(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id, ParentId = root.Id, Name = "clip.mkv", NameNorm = "clip.mkv", Extension = "mkv",
|
||||
PathRel = "clip.mkv", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id, ParentId = root.Id, Name = "notes.pdf", NameNorm = "notes.pdf", Extension = "pdf",
|
||||
PathRel = "notes.pdf", LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
var search = new SearchService(store);
|
||||
var hinted = await search.SearchAsync(new SearchQuery { Text = "category:video", SourceIds = [source.Id] });
|
||||
Assert.Single(hinted);
|
||||
Assert.Equal("clip.mkv", hinted[0].Name);
|
||||
var filtered = await search.SearchAsync(new SearchQuery { Category = FileCategory.Documents, SourceIds = [source.Id] });
|
||||
Assert.Single(filtered);
|
||||
Assert.Equal("notes.pdf", filtered[0].Name);
|
||||
var split = SearchService.SplitCategoryHint("vacation category:photos");
|
||||
Assert.Equal("vacation", split.Name);
|
||||
Assert.Equal(FileCategory.Photos, split.Category);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,135 @@ public class EntryAndSearchTests
|
||||
Assert.Contains(byDate, e => e.Name == "Movie.mkv");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_assigns_and_preserves_categories()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\media");
|
||||
var root = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "media",
|
||||
NameNorm = "media",
|
||||
IsDirectory = true,
|
||||
PathRel = "",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
root.Id = await store.Entries.UpsertAsync(root);
|
||||
var photo = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "shot.jpg",
|
||||
NameNorm = "shot.jpg",
|
||||
Extension = "jpg",
|
||||
PathRel = "shot.jpg",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
photo.Id = await store.Entries.UpsertAsync(photo);
|
||||
var loaded = await store.Entries.GetAsync(photo.Id);
|
||||
Assert.Equal(FileCategory.Photos, loaded!.Category);
|
||||
Assert.Equal(CategorySources.Extension, loaded.CategorySource);
|
||||
|
||||
await store.Entries.SetUserCategoryAsync(photo.Id, FileCategory.Documents, "Manual");
|
||||
photo.SizeBytes = 100;
|
||||
await store.Entries.UpsertAsync(photo);
|
||||
loaded = await store.Entries.GetAsync(photo.Id);
|
||||
Assert.Equal(FileCategory.Documents, loaded!.Category);
|
||||
Assert.Equal(CategorySources.User, loaded.CategorySource);
|
||||
|
||||
var other = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = root.Id,
|
||||
Name = "clip.mkv",
|
||||
NameNorm = "clip.mkv",
|
||||
Extension = "mkv",
|
||||
PathRel = "clip.mkv",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
other.Id = await store.Entries.UpsertAsync(other);
|
||||
await store.Entries.RefreshCategoryFromChildrenAsync(root.Id);
|
||||
var folder = await store.Entries.GetAsync(root.Id);
|
||||
Assert.Equal(FileCategory.Unknown, folder!.Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Archive_contents_and_signature_queues()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\media");
|
||||
var zip = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "photos.zip",
|
||||
NameNorm = "photos.zip",
|
||||
Extension = "zip",
|
||||
PathRel = "photos.zip",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
zip.Id = await store.Entries.UpsertAsync(zip);
|
||||
Assert.Equal(FileCategory.Archive, (await store.Entries.GetAsync(zip.Id))!.Category);
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await store.Entries.UpsertAsync(new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
ParentId = zip.Id,
|
||||
Name = $"shot{i}.jpg",
|
||||
NameNorm = $"shot{i}.jpg",
|
||||
Extension = "jpg",
|
||||
PathRel = $"photos.zip/shot{i}.jpg",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
var needing = await store.Entries.GetArchiveIdsNeedingContentClassifyAsync(10);
|
||||
Assert.Contains(zip.Id, needing);
|
||||
await store.Entries.RefreshCategoryFromChildrenAsync(zip.Id);
|
||||
var loaded = await store.Entries.GetAsync(zip.Id);
|
||||
Assert.Equal(FileCategory.Photos, loaded!.Category);
|
||||
Assert.Equal(CategorySources.ArchiveContents, loaded.CategorySource);
|
||||
Assert.Empty(await store.Entries.GetArchiveIdsNeedingContentClassifyAsync(10));
|
||||
|
||||
var mystery = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "mystery.bin",
|
||||
NameNorm = "mystery.bin",
|
||||
Extension = "bin",
|
||||
PathRel = "mystery.bin",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
mystery.Id = await store.Entries.UpsertAsync(mystery);
|
||||
var unknowns = await store.Entries.GetUnknownFilesForSignatureAsync(10);
|
||||
Assert.Contains(unknowns, e => e.Id == mystery.Id);
|
||||
Assert.Equal(0, await store.Entries.BackfillCheapCategoriesAsync(50));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_category_by_path_is_preserved()
|
||||
{
|
||||
await using var store = await Stores.Open();
|
||||
var source = await store.AddSourceAsync(@"C:\media");
|
||||
var photo = new IndexEntry
|
||||
{
|
||||
SourceId = source.Id,
|
||||
Name = "shot.jpg",
|
||||
NameNorm = "shot.jpg",
|
||||
Extension = "jpg",
|
||||
PathRel = "shot.jpg",
|
||||
LastSeenUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
photo.Id = await store.Entries.UpsertAsync(photo);
|
||||
var mutations = new Explorer.Application.LocalIndexMutations(store);
|
||||
await mutations.SetUserCategoryByPathAsync(@"C:\media\shot.jpg", FileCategory.Installer, "Manual");
|
||||
var loaded = await store.Entries.GetAsync(photo.Id);
|
||||
Assert.Equal(FileCategory.Installer, loaded!.Category);
|
||||
Assert.Equal(CategorySources.User, loaded.CategorySource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Folder_aggregates_and_tombstones()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user