Add queued FFmpeg conversion and finish splitting the window from the host.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
109
tests/Explorer.Application.Tests/ConversionPlannerTests.cs
Normal file
109
tests/Explorer.Application.Tests/ConversionPlannerTests.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class ConversionPlannerTests
|
||||
{
|
||||
private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z");
|
||||
|
||||
[Fact]
|
||||
public void Video_files_become_mp4_outputs()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\clip.mov", 10, T0)
|
||||
.File(@"C:\src\notes.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build([@"C:\src"], @"C:\dst", ConversionKind.VideoToMp4, fs);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
var op = Assert.Single(plan.Operations);
|
||||
Assert.Equal(TransferOp.Convert, op.Op);
|
||||
Assert.Equal(@"C:\src\clip.mov", op.SourcePath);
|
||||
Assert.Equal(@"C:\dst\clip.mp4", op.DestinationPath);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("not a match", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Online_only_files_are_skipped()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\clip.mov", 10, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(
|
||||
[@"C:\src\clip.mov"],
|
||||
@"C:\dst",
|
||||
ConversionKind.VideoToMp4,
|
||||
fs,
|
||||
wouldHydrate: item => item.Name == "clip.mov");
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_ffmpeg_is_an_error()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").File(@"C:\src\clip.mov", 10, T0).Dir(@"C:\dst");
|
||||
var plan = Build([@"C:\src\clip.mov"], @"C:\dst", ConversionKind.VideoToMp4, fs, ffmpegAvailable: false);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("FFmpeg", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unique_names_avoid_overwrite()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\clip.mov", 10, T0)
|
||||
.File(@"C:\src\clip.mkv", 10, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(
|
||||
[@"C:\src"],
|
||||
@"C:\dst",
|
||||
ConversionKind.VideoToMp4,
|
||||
fs,
|
||||
pathExists: path => path.Equals(@"C:\dst\clip.mp4", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.True(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\dst\clip (2).mp4");
|
||||
Assert.Contains(plan.Operations, o => o.DestinationPath == @"C:\dst\clip (3).mp4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heic_maps_to_jpeg()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\IMG_0001.HEIC", 4, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build([@"C:\src\IMG_0001.HEIC"], @"C:\dst", ConversionKind.HeicToJpeg, fs);
|
||||
var op = Assert.Single(plan.Operations);
|
||||
Assert.Equal(@"C:\dst\IMG_0001.jpg", op.DestinationPath);
|
||||
Assert.Equal(nameof(ConversionKind.HeicToJpeg), op.NewName);
|
||||
}
|
||||
|
||||
private static OperationPlan Build(
|
||||
string[] sources,
|
||||
string dest,
|
||||
ConversionKind kind,
|
||||
IFileSystemEnumerator fs,
|
||||
bool ffmpegAvailable = true,
|
||||
Func<string, bool>? pathExists = null,
|
||||
Func<FileSystemItem, bool>? wouldHydrate = null)
|
||||
=> new ConversionPlanner().Build(
|
||||
sources,
|
||||
dest,
|
||||
kind,
|
||||
fs,
|
||||
ffmpegAvailable,
|
||||
FfmpegLocator.MissingHint,
|
||||
pathExists ?? (_ => false),
|
||||
wouldHydrate);
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
30
tests/Explorer.Application.Tests/FfmpegLocatorTests.cs
Normal file
30
tests/Explorer.Application.Tests/FfmpegLocatorTests.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Explorer.Application;
|
||||
|
||||
namespace Explorer.Application.Tests;
|
||||
|
||||
public class FfmpegLocatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Prefers_the_configured_path_when_it_exists()
|
||||
{
|
||||
var path = @"C:\Tools\ffmpeg.exe";
|
||||
Assert.Equal(path, FfmpegLocator.Find(path, fileExists: p => p == path, pathVariable: ""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Finds_ffmpeg_on_PATH_when_not_configured()
|
||||
{
|
||||
var found = FfmpegLocator.Find(
|
||||
null,
|
||||
fileExists: p => p.Equals(@"D:\bin\ffmpeg.exe", StringComparison.OrdinalIgnoreCase),
|
||||
pathVariable: @"C:\Windows;D:\bin");
|
||||
Assert.Equal(@"D:\bin\ffmpeg.exe", found);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Returns_null_when_ffmpeg_is_missing()
|
||||
{
|
||||
Assert.Null(FfmpegLocator.Find(null, fileExists: _ => false, pathVariable: @"C:\none"));
|
||||
Assert.Contains("FFmpeg", FfmpegLocator.MissingHint, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,44 @@ public class FileOperationProfilePlannerTests
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("7-Zip", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Convert_lists_one_job_per_matching_file()
|
||||
{
|
||||
var fs = Tree()
|
||||
.Dir(@"C:\src")
|
||||
.File(@"C:\src\clip.mov", 10, T0)
|
||||
.File(@"C:\src\notes.txt", 1, T0)
|
||||
.Dir(@"C:\dst");
|
||||
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Convert",
|
||||
DestPath = @"C:\dst",
|
||||
DoConvert = true,
|
||||
ConversionKind = ConversionKind.VideoToMp4
|
||||
}, fs, [@"C:\src"]);
|
||||
Assert.True(plan.CanEnqueue);
|
||||
var convert = Assert.Single(plan.Operations);
|
||||
Assert.Equal(TransferOp.Convert, convert.Op);
|
||||
Assert.Equal(@"C:\src\clip.mov", convert.SourcePath);
|
||||
Assert.Equal(@"C:\dst\clip.mp4", convert.DestinationPath);
|
||||
Assert.Contains(plan.ProfilePreview, r => r.Action == "Convert");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_ffmpeg_is_an_error()
|
||||
{
|
||||
var fs = Tree().Dir(@"C:\src").File(@"C:\src\clip.mov", 10, T0).Dir(@"C:\dst");
|
||||
var plan = Build(new OperationProfile
|
||||
{
|
||||
Name = "Convert",
|
||||
DestPath = @"C:\dst",
|
||||
DoConvert = true
|
||||
}, fs, [@"C:\src"], convertAvailable: false);
|
||||
Assert.False(plan.CanEnqueue);
|
||||
Assert.Contains(plan.Issues, i => i.Message.Contains("FFmpeg", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static OperationProfile CopyProfile(string dest = @"C:\dst", bool git = false)
|
||||
=> new()
|
||||
{
|
||||
@@ -176,6 +214,7 @@ public class FileOperationProfilePlannerTests
|
||||
GitStatus? git = null,
|
||||
bool gitAvailable = true,
|
||||
bool compressAvailable = true,
|
||||
bool convertAvailable = true,
|
||||
Func<string, bool>? reachable = null,
|
||||
Func<FileSystemItem, bool>? wouldHydrate = null)
|
||||
=> new FileOperationProfilePlanner(new RenamePlanner()).Build(
|
||||
@@ -188,7 +227,9 @@ public class FileOperationProfilePlannerTests
|
||||
compressAvailable,
|
||||
SevenZipLocator.MissingHint,
|
||||
pathExists: _ => false,
|
||||
wouldHydrate: wouldHydrate);
|
||||
wouldHydrate: wouldHydrate,
|
||||
convertAvailable: convertAvailable,
|
||||
convertMissingHint: FfmpegLocator.MissingHint);
|
||||
|
||||
private static TreeEnumerator Tree() => new();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class UiPreferencesStoreTests
|
||||
Assert.True(prefs.AutoClearQueueWhenDone);
|
||||
Assert.Equal(@"C:\Program Files\7-Zip\7z.exe", prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
Assert.Null(prefs.FfmpegPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -43,6 +44,13 @@ public class UiPreferencesStoreTests
|
||||
Assert.Equal(@"C:\Program Files\Git\cmd\git.exe", prefs.GitPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_ffmpeg_path()
|
||||
{
|
||||
var prefs = UiPreferencesStore.Parse(["ffmpeg=C:\\Tools\\ffmpeg.exe"]);
|
||||
Assert.Equal(@"C:\Tools\ffmpeg.exe", prefs.FfmpegPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_host_and_removable_index_flags()
|
||||
{
|
||||
@@ -70,6 +78,8 @@ public class UiPreferencesStoreTests
|
||||
Assert.False(prefs.BackgroundHostAtLogon);
|
||||
Assert.Null(prefs.SevenZipPath);
|
||||
Assert.Null(prefs.GitPath);
|
||||
Assert.Null(prefs.FfmpegPath);
|
||||
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -91,6 +101,43 @@ public class UiPreferencesStoreTests
|
||||
Assert.Equal(24, prefs.WindowTop);
|
||||
Assert.True(prefs.WindowMaximized);
|
||||
Assert.Equal(320, prefs.TreeWidth);
|
||||
Assert.True(prefs.SessionTabs is null || prefs.SessionTabs.Count == 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_session_tabs_and_active_index()
|
||||
{
|
||||
var left = @"C:\Users\Dominique\Documents";
|
||||
var right = @"D:\Photos";
|
||||
var prefs = UiPreferencesStore.Parse(
|
||||
[
|
||||
"session-active-tab=1",
|
||||
"session-tab=" + UiPreferencesStore.FormatSessionTab(new SessionTabState(LocationRoots.ThisPc)),
|
||||
"session-tab=" + UiPreferencesStore.FormatSessionTab(new SessionTabState(left, right, true, 0.42, true))
|
||||
]);
|
||||
Assert.Equal(1, prefs.SessionActiveTab);
|
||||
Assert.NotNull(prefs.SessionTabs);
|
||||
Assert.Equal(2, prefs.SessionTabs.Count);
|
||||
Assert.Equal(LocationRoots.ThisPc, prefs.SessionTabs[0].LeftPath);
|
||||
Assert.False(prefs.SessionTabs[0].IsSplit);
|
||||
Assert.Equal(left, prefs.SessionTabs[1].LeftPath);
|
||||
Assert.Equal(right, prefs.SessionTabs[1].RightPath);
|
||||
Assert.True(prefs.SessionTabs[1].IsSplit);
|
||||
Assert.Equal(0.42, prefs.SessionTabs[1].SplitRatio);
|
||||
Assert.True(prefs.SessionTabs[1].ActiveIsRight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_tab_roundtrip_escapes_semicolons_in_paths()
|
||||
{
|
||||
var state = new SessionTabState(@"C:\weird;name", @"\\server\share", true, 0.3, false);
|
||||
var parsed = UiPreferencesStore.TryParseSessionTab(UiPreferencesStore.FormatSessionTab(state));
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(state.LeftPath, parsed.LeftPath);
|
||||
Assert.Equal(state.RightPath, parsed.RightPath);
|
||||
Assert.True(parsed.IsSplit);
|
||||
Assert.Equal(0.3, parsed.SplitRatio);
|
||||
Assert.False(parsed.ActiveIsRight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,7 +147,17 @@ public class UiPreferencesStoreTests
|
||||
try
|
||||
{
|
||||
var store = new UiPreferencesStore(new PrefsEnv(dir));
|
||||
store.Save(new UiPreferences("Light", true, false, true, AutoClearQueueWhenDone: true, WindowWidth: 1100, WindowHeight: 720, TreeWidth: 300));
|
||||
store.Save(new UiPreferences(
|
||||
"Light", true, false, true,
|
||||
AutoClearQueueWhenDone: true,
|
||||
WindowWidth: 1100,
|
||||
WindowHeight: 720,
|
||||
TreeWidth: 300,
|
||||
SessionTabs:
|
||||
[
|
||||
new SessionTabState(@"C:\Temp", @"D:\", true, 0.6, true)
|
||||
],
|
||||
SessionActiveTab: 0));
|
||||
var loaded = store.Load();
|
||||
Assert.Equal("Light", loaded.Theme);
|
||||
Assert.True(loaded.GroupNetworkPlaces);
|
||||
@@ -114,6 +171,13 @@ public class UiPreferencesStoreTests
|
||||
Assert.Equal(1100, loaded.WindowWidth);
|
||||
Assert.Equal(720, loaded.WindowHeight);
|
||||
Assert.Equal(300, loaded.TreeWidth);
|
||||
Assert.NotNull(loaded.SessionTabs);
|
||||
var tab = Assert.Single(loaded.SessionTabs);
|
||||
Assert.Equal(@"C:\Temp", tab.LeftPath);
|
||||
Assert.Equal(@"D:\", tab.RightPath);
|
||||
Assert.True(tab.IsSplit);
|
||||
Assert.Equal(0.6, tab.SplitRatio);
|
||||
Assert.True(tab.ActiveIsRight);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -110,6 +110,29 @@ public class ArchiveFormatsTests
|
||||
}
|
||||
}
|
||||
|
||||
public class ConversionFormatsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Matches_video_audio_and_heic()
|
||||
{
|
||||
Assert.True(ConversionFormats.Matches("clip.MOV", ConversionKind.VideoToMp4));
|
||||
Assert.True(ConversionFormats.Matches("talk.wav", ConversionKind.ExtractAudio));
|
||||
Assert.True(ConversionFormats.Matches("film.mkv", ConversionKind.ExtractAudio));
|
||||
Assert.True(ConversionFormats.Matches("IMG_0001.heic", ConversionKind.HeicToJpeg));
|
||||
Assert.False(ConversionFormats.Matches("notes.txt", ConversionKind.VideoToMp4));
|
||||
Assert.False(ConversionFormats.IsConvertible("notes.txt"));
|
||||
Assert.True(ConversionFormats.IsConvertible("phone.mp4"));
|
||||
Assert.Equal("mp4", ConversionFormats.Extension(ConversionKind.VideoToMp4));
|
||||
Assert.Equal("m4a", ConversionFormats.Extension(ConversionKind.ExtractAudio));
|
||||
Assert.Equal("jpg", ConversionFormats.Extension(ConversionKind.HeicToJpeg));
|
||||
Assert.Equal(ConversionKind.VideoToMp4, ConversionFormats.Preferred(["clip.mov", "notes.txt"]));
|
||||
Assert.Equal(ConversionKind.HeicToJpeg, ConversionFormats.Preferred(["IMG.HEIC"]));
|
||||
Assert.Equal(ConversionKind.VideoToMp4, ConversionFormats.Infer(@"C:\a.mov", @"C:\a.mp4"));
|
||||
Assert.Equal(ConversionKind.ExtractAudio, ConversionFormats.Infer(@"C:\a.mov", @"C:\a.m4a"));
|
||||
Assert.Equal(ConversionKind.HeicToJpeg, ConversionFormats.Infer(@"C:\a.heic", @"C:\a.jpg"));
|
||||
}
|
||||
}
|
||||
|
||||
public class VolumeIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -386,6 +409,9 @@ public class OperationProfileTests
|
||||
var compress = new OperationProfile { Name = "Archive", DoCopy = true, DoCompress = true, AutoRun = true };
|
||||
Assert.False(compress.CanAutoRun);
|
||||
|
||||
var convert = new OperationProfile { Name = "Convert", DoCopy = true, DoConvert = true, AutoRun = true };
|
||||
Assert.False(convert.CanAutoRun);
|
||||
|
||||
var rename = new OperationProfile { Name = "Rename", DoCopy = true, DoRename = true, RenamePrefix = "x_", AutoRun = true };
|
||||
Assert.False(rename.CanAutoRun);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,21 @@ public class OperationProfileServiceTests
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Convert_auto_run_is_ignored()
|
||||
{
|
||||
await using var ctx = await ProfileHarness.CreateAsync();
|
||||
ctx.Profile.DoConvert = true;
|
||||
ctx.Profile.AutoRun = true;
|
||||
await ctx.Profiles.SaveAsync(ctx.Profile);
|
||||
Assert.False((await ctx.Store.OperationProfiles.GetAsync(ctx.Profile.Id))!.CanAutoRun);
|
||||
ctx.Volumes.Reachable = false;
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
ctx.Volumes.Reachable = true;
|
||||
await ctx.Profiles.TryAutoRunAsync();
|
||||
Assert.Empty(ctx.Queue.Snapshot());
|
||||
}
|
||||
|
||||
private static async Task WaitUntil(Func<bool> condition)
|
||||
{
|
||||
var limit = DateTime.UtcNow + TimeSpan.FromSeconds(4);
|
||||
@@ -119,7 +134,8 @@ public class OperationProfileServiceTests
|
||||
enumerator,
|
||||
git,
|
||||
new NeverHydrate(),
|
||||
new FakeArchiveExecutor());
|
||||
new FakeArchiveExecutor(),
|
||||
new FakeConversionExecutor());
|
||||
return new ProfileHarness
|
||||
{
|
||||
Profiles = profiles,
|
||||
|
||||
@@ -395,6 +395,48 @@ public class TransferQueueTests
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Convert_fails_when_ffmpeg_is_missing()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync();
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ops.ConvertAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "a.mp4"), ConversionKind.VideoToMp4);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Convert && j.Status == TransferStatus.Failed));
|
||||
var job = ctx.Queue.Snapshot().Single(j => j.Op == TransferOp.Convert);
|
||||
Assert.Contains("FFmpeg", job.Error, StringComparison.OrdinalIgnoreCase);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fake_convert_writes_output()
|
||||
{
|
||||
var fake = new FakeConversionExecutor();
|
||||
await using var ctx = await Harness.CreateAsync(conversion: fake);
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
var dest = Path.Combine(ctx.Dest, "a.mp4");
|
||||
await ops.ConvertAsync(ctx.File("a.txt"), dest, ConversionKind.VideoToMp4);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Convert && j.Status == TransferStatus.Done));
|
||||
Assert.True(File.Exists(dest));
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Convert_refuses_online_only_cloud_files()
|
||||
{
|
||||
await using var ctx = await Harness.CreateAsync(hydration: new AlwaysHydrate(), conversion: new FakeConversionExecutor());
|
||||
var ops = new FileOperationService(ctx.Queue, ctx.Shell, new DiskEnum());
|
||||
await ctx.Queue.StartAsync(CancellationToken.None);
|
||||
await ops.ConvertAsync(ctx.File("a.txt"), Path.Combine(ctx.Dest, "a.mp4"), ConversionKind.VideoToMp4);
|
||||
await WaitUntil(() => ctx.Queue.Snapshot().Any(j =>
|
||||
j.Op == TransferOp.Convert && j.Status == TransferStatus.Failed));
|
||||
Assert.Equal(FileOperationErrors.CloudHydration, ctx.Queue.Snapshot().Single().Error);
|
||||
await ctx.Queue.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_recycle_bin_goes_through_the_queue()
|
||||
{
|
||||
@@ -438,7 +480,8 @@ public class TransferQueueTests
|
||||
|
||||
public static async Task<Harness> CreateAsync(
|
||||
IArchiveExecutor? archives = null,
|
||||
IHydrationGuard? hydration = null)
|
||||
IHydrationGuard? hydration = null,
|
||||
IMediaConversionProvider? conversion = null)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "ew-xfer", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
@@ -452,7 +495,7 @@ public class TransferQueueTests
|
||||
var shell = new GateShell();
|
||||
var volumes = new ControlledVolumes();
|
||||
var queue = new TransferQueue(
|
||||
new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration),
|
||||
new NativeFileOperationExecutor(shell, new DiskEnum(), archives, hydration, conversion),
|
||||
store,
|
||||
volumes,
|
||||
NullLogger<TransferQueue>.Instance);
|
||||
@@ -629,6 +672,24 @@ internal sealed class FakeArchiveExecutor : IArchiveExecutor
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class FakeConversionExecutor : IMediaConversionProvider
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
public string MissingHint => FfmpegLocator.MissingHint;
|
||||
|
||||
public async Task ConvertAsync(
|
||||
string sourcePath,
|
||||
string destinationPath,
|
||||
ConversionKind kind,
|
||||
IProgress<ConversionProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
await File.WriteAllTextAsync(destinationPath, "converted:" + kind, cancellationToken).ConfigureAwait(false);
|
||||
progress?.Report(new ConversionProgress(100, destinationPath));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AlwaysHydrate : IHydrationGuard
|
||||
{
|
||||
public bool WouldHydrateOnRead(FileSystemItem item) => true;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using Explorer.Analysis;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.Hosting;
|
||||
using Explorer.Indexing;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Explorer.Search;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
@@ -26,6 +31,10 @@ public class CoreRegistrationTests
|
||||
Assert.NotNull(sp.GetService<ITransferHost>());
|
||||
Assert.NotNull(sp.GetService<IIndexMutations>());
|
||||
Assert.Null(sp.GetService<IOsClipboard>());
|
||||
Assert.NotNull(sp.GetService<FilesystemScanner>());
|
||||
Assert.NotEmpty(sp.GetServices<IStorageProvider>());
|
||||
Assert.NotNull(sp.GetService<ICloudOverlay>());
|
||||
Assert.IsType<StorageProviderRegistry>(sp.GetService<ICloudOverlay>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -52,9 +61,22 @@ public class CoreRegistrationTests
|
||||
Assert.Same(workbench.Sources, sp.GetService<ISourceHost>());
|
||||
Assert.Same(workbench.Mutations, sp.GetService<IIndexMutations>());
|
||||
Assert.False(sp.GetRequiredService<IIndexStore>().CanWrite);
|
||||
Assert.NotNull(sp.GetService<BrowseService>());
|
||||
Assert.NotNull(sp.GetService<SearchService>());
|
||||
Assert.NotNull(sp.GetService<AnalysisService>());
|
||||
Assert.NotNull(sp.GetService<Explorer.FileOperations.FileOperationService>());
|
||||
Assert.NotNull(sp.GetService<Explorer.FileOperations.FolderSyncService>());
|
||||
Assert.Same(NullCloudOverlay.Instance, sp.GetService<ICloudOverlay>());
|
||||
Assert.Null(sp.GetService<FilesystemScanner>());
|
||||
Assert.Null(sp.GetService<FolderReconciler>());
|
||||
Assert.Null(sp.GetService<UsnChangeApplier>());
|
||||
Assert.Null(sp.GetService<ArchiveContentsIndexer>());
|
||||
Assert.Null(sp.GetService<IUsnJournal>());
|
||||
Assert.Null(sp.GetService<IElevatedScanService>());
|
||||
Assert.Empty(sp.GetServices<IStorageProvider>());
|
||||
var hosted = sp.GetServices<IHostedService>().ToList();
|
||||
Assert.Contains(hosted, s => s is IndexStoreLifetime);
|
||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService");
|
||||
Assert.DoesNotContain(hosted, s => s.GetType().Name is "IndexingCoordinator" or "TransferQueue" or "WatcherHostedService" or "DuplicateHashWorker" or "HistoryRollupService");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Contracts\Explorer.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Hosting.Client\Explorer.Hosting.Client.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Hosting\Explorer.Hosting.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,15 +5,12 @@ namespace Explorer.Hosting.Tests;
|
||||
public class HostLogonAutostartTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_args_are_per_user_logon_not_system_service()
|
||||
public void Run_command_is_the_quoted_host_exe_for_the_current_user()
|
||||
{
|
||||
var args = HostLogonAutostart.CreateTaskArgs(@"C:\Tools\Explorer.Host.exe");
|
||||
Assert.Contains("/SC", args);
|
||||
Assert.Contains("ONLOGON", args);
|
||||
Assert.Contains("/RL", args);
|
||||
Assert.Contains("LIMITED", args);
|
||||
Assert.Contains(HostLogonAutostart.TaskName, args);
|
||||
Assert.DoesNotContain("ONSTART", args);
|
||||
Assert.DoesNotContain("/RU", args);
|
||||
var command = HostLogonAutostart.RunCommand(@"C:\Tools\Explorer.Host.exe");
|
||||
Assert.Equal("\"C:\\Tools\\Explorer.Host.exe\"", command);
|
||||
Assert.Equal("ExplorerWorkbenchHost", HostLogonAutostart.RunValueName);
|
||||
Assert.DoesNotContain("/RU", command, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("ONSTART", command, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
67
tests/Explorer.Hosting.Tests/ProjectGraphTests.cs
Normal file
67
tests/Explorer.Hosting.Tests/ProjectGraphTests.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
namespace Explorer.Hosting.Tests;
|
||||
|
||||
public class ProjectGraphTests
|
||||
{
|
||||
[Fact]
|
||||
public void App_references_client_not_host_runtime_or_plugin_implementations()
|
||||
{
|
||||
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.App", "Explorer.App.csproj"));
|
||||
Assert.Contains("Explorer.Hosting.Client", csproj);
|
||||
Assert.Contains("Explorer.Presentation", csproj);
|
||||
Assert.DoesNotContain("Explorer.Hosting\\Explorer.Hosting.csproj", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
|
||||
Assert.DoesNotContain("Explorer.Indexing", csproj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Client_does_not_reference_plugin_implementations_or_scanners()
|
||||
{
|
||||
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Hosting.Client", "Explorer.Hosting.Client.csproj"));
|
||||
Assert.Contains("Explorer.Plugin.Abstractions", csproj);
|
||||
Assert.Contains("Explorer.Storage.Sqlite", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
|
||||
Assert.DoesNotContain("Explorer.Indexing", csproj);
|
||||
Assert.DoesNotContain("Explorer.Hosting\\Explorer.Hosting.csproj", csproj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Host_runtime_owns_plugins_and_references_the_client()
|
||||
{
|
||||
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Hosting", "Explorer.Hosting.csproj"));
|
||||
Assert.Contains("Explorer.Hosting.Client", csproj);
|
||||
Assert.Contains("Explorer.Plugin.OneDrive", csproj);
|
||||
Assert.Contains("Explorer.Plugin.GoogleDrive", csproj);
|
||||
Assert.Contains("Explorer.Plugin.Nextcloud", csproj);
|
||||
Assert.Contains("Explorer.Indexing", csproj);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Presentation_may_reference_plugin_abstractions_not_implementations()
|
||||
{
|
||||
var csproj = File.ReadAllText(Path.Combine(RepoRoot(), "src", "Explorer.Presentation", "Explorer.Presentation.csproj"));
|
||||
Assert.Contains("Explorer.Plugin.Abstractions", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.OneDrive", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.GoogleDrive", csproj);
|
||||
Assert.DoesNotContain("Explorer.Plugin.Nextcloud", csproj);
|
||||
}
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir.FullName, "Explorer.slnx")))
|
||||
{
|
||||
return dir.FullName;
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not find Explorer.slnx above " + AppContext.BaseDirectory);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Explorer.Application;
|
||||
using Explorer.Contracts;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Hosting.Ipc;
|
||||
using Explorer.Plugin.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Explorer.Hosting.Tests;
|
||||
@@ -13,10 +17,7 @@ public class WorkbenchPipeTests
|
||||
{
|
||||
var indexing = new FakeIndexing();
|
||||
var transfers = new FakeTransfers();
|
||||
var server = new WorkbenchPipeServer(
|
||||
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()),
|
||||
new WorkbenchIpcOptions { PipeName = "ew-test" },
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var server = CreateServer(indexing, transfers);
|
||||
|
||||
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
|
||||
Assert.True(ping.Ok);
|
||||
@@ -46,15 +47,122 @@ public class WorkbenchPipeTests
|
||||
[Fact]
|
||||
public void Handle_rejects_other_protocol_versions()
|
||||
{
|
||||
var server = new WorkbenchPipeServer(
|
||||
new WorkbenchHost(new FakeIndexing(), new FakeTransfers(), new StubSources(), new StubMutations()),
|
||||
new WorkbenchIpcOptions(),
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var server = CreateServer(new FakeIndexing(), new FakeTransfers());
|
||||
var reply = server.Handle(new IpcEnvelope { V = 99, Op = "Ping" });
|
||||
Assert.False(reply.Ok);
|
||||
Assert.Contains("99", reply.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_to_a_missing_pipe_fails_quickly()
|
||||
{
|
||||
var options = new WorkbenchIpcOptions { PipeName = "ew-missing-" + Guid.NewGuid().ToString("N") };
|
||||
var started = Stopwatch.GetTimestamp();
|
||||
await Assert.ThrowsAsync<TimeoutException>(() =>
|
||||
WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromMilliseconds(400)));
|
||||
Assert.True(
|
||||
Stopwatch.GetElapsedTime(started) < TimeSpan.FromSeconds(3),
|
||||
"Named-pipe connect hung instead of timing out.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_cloud_places_does_not_need_a_workbench()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ICloudOverlay>(NullCloudOverlay.Instance);
|
||||
using var sp = services.BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
new WorkbenchIpcOptions(),
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Cloud.Places" });
|
||||
Assert.True(reply.Ok);
|
||||
var places = JsonSerializer.Deserialize<ProviderPlace[]>(reply.Payload ?? "null", WorkbenchIpc.Json);
|
||||
Assert.NotNull(places);
|
||||
Assert.Empty(places);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsListening_does_not_consume_the_server_instance()
|
||||
{
|
||||
var options = new WorkbenchIpcOptions { PipeName = "ew-probe-" + Guid.NewGuid().ToString("N") };
|
||||
using var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
options,
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
await server.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await server.Listening.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
Assert.True(WorkbenchIpc.IsListening(options.PipeName, 200));
|
||||
await using var client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_host_shutdown_does_not_need_a_workbench()
|
||||
{
|
||||
using var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
new WorkbenchIpcOptions(),
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var stopped = false;
|
||||
server.ShutdownRequested = () => stopped = true;
|
||||
var reply = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Host.Shutdown" });
|
||||
Assert.True(reply.Ok);
|
||||
Assert.True(stopped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ping_does_not_need_a_workbench()
|
||||
{
|
||||
using var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
new WorkbenchIpcOptions(),
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
var ping = server.Handle(new IpcEnvelope { V = WorkbenchIpc.ProtocolVersion, Op = "Ping" });
|
||||
Assert.True(ping.Ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ping_roundtrip_over_a_live_named_pipe()
|
||||
{
|
||||
var options = new WorkbenchIpcOptions { PipeName = "ew-live-" + Guid.NewGuid().ToString("N") };
|
||||
using var sp = new ServiceCollection().BuildServiceProvider();
|
||||
var server = new WorkbenchPipeServer(
|
||||
sp,
|
||||
options,
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
await server.StartAsync(CancellationToken.None);
|
||||
try
|
||||
{
|
||||
await server.Listening.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
await using var client = await WorkbenchPipeClient.ConnectAsync(options, TimeSpan.FromSeconds(3));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await server.StopAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkbenchPipeServer CreateServer(FakeIndexing indexing, FakeTransfers transfers)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IWorkbenchHost>(
|
||||
new WorkbenchHost(indexing, transfers, new StubSources(), new StubMutations()));
|
||||
return new WorkbenchPipeServer(
|
||||
services.BuildServiceProvider(),
|
||||
new WorkbenchIpcOptions { PipeName = "ew-test" },
|
||||
NullLogger<WorkbenchPipeServer>.Instance);
|
||||
}
|
||||
|
||||
private sealed class FakeIndexing : IIndexingHost
|
||||
{
|
||||
public long FullScanId { get; private set; }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Explorer.Domain\Explorer.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.FileOperations\Explorer.FileOperations.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Indexing\Explorer.Indexing.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Application\Explorer.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Explorer.Storage.Sqlite\Explorer.Storage.Sqlite.csproj" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Explorer.Application;
|
||||
using Explorer.Domain;
|
||||
using Explorer.Domain.Abstractions;
|
||||
using Explorer.FileOperations;
|
||||
using Explorer.Indexing;
|
||||
using Explorer.Storage.Sqlite;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -393,6 +393,8 @@ public class FileRelationTests
|
||||
RequireGitClean = true,
|
||||
DoCompress = true,
|
||||
ArchiveFormat = ArchiveFormat.SevenZip,
|
||||
DoConvert = true,
|
||||
ConversionKind = ConversionKind.HeicToJpeg,
|
||||
DoCopy = false,
|
||||
DoRename = true,
|
||||
RenamePrefix = "x_",
|
||||
@@ -410,6 +412,8 @@ public class FileRelationTests
|
||||
Assert.True(loaded.RequireGitClean);
|
||||
Assert.True(loaded.DoCompress);
|
||||
Assert.Equal(ArchiveFormat.SevenZip, loaded.ArchiveFormat);
|
||||
Assert.True(loaded.DoConvert);
|
||||
Assert.Equal(ConversionKind.HeicToJpeg, loaded.ConversionKind);
|
||||
Assert.True(loaded.DoRename);
|
||||
Assert.Equal("x_", loaded.RenamePrefix);
|
||||
Assert.Equal(".git\nbin", loaded.Excludes);
|
||||
@@ -471,4 +475,16 @@ public class IndexStoreLockTests
|
||||
() => reader.RunWriteAsync(_ => Task.CompletedTask));
|
||||
Assert.Contains("read-only", write.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsHeld_is_true_while_a_writer_is_open()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "ew-tests", Guid.NewGuid().ToString("N"), "index.db");
|
||||
Assert.False(IndexStoreLock.IsHeld(path));
|
||||
await using var writer = new SqliteIndexStore(path, NullLogger<SqliteIndexStore>.Instance);
|
||||
await writer.OpenAsync();
|
||||
Assert.True(await Task.Run(() => IndexStoreLock.IsHeld(path)));
|
||||
await writer.DisposeAsync();
|
||||
Assert.False(IndexStoreLock.IsHeld(path));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user