Add queued FFmpeg conversion and finish splitting the window from the host.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 01:49:50 +02:00
parent 48d03f794f
commit 6fc7506eb7
85 changed files with 3394 additions and 512 deletions

View 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();
}

View 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);
}
}

View File

@@ -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();
}

View File

@@ -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
{