using Explorer.Application; using Explorer.Domain; using Explorer.Domain.Abstractions; namespace Explorer.Application.Tests; public class FileOperationProfilePlannerTests { private static readonly DateTimeOffset T0 = DateTimeOffset.Parse("2024-06-01T12:00:00Z"); [Fact] public void Copy_lists_copy_operations_only() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\a.txt", 1, T0) .File(@"C:\src\b.txt", 2, T0) .Dir(@"C:\dst"); var plan = Build(CopyProfile(), fs, [@"C:\src"]); Assert.True(plan.CanEnqueue); Assert.All(plan.Operations, o => Assert.Equal(TransferOp.Copy, o.Op)); Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\src\a.txt" && o.DestinationPath == @"C:\dst\src\a.txt"); Assert.Contains(plan.Operations, o => o.SourcePath == @"C:\src\b.txt" && o.DestinationPath == @"C:\dst\src\b.txt"); Assert.Contains(plan.ProfilePreview, r => r.Action == "Copy" && r.Path == @"C:\dst\src\a.txt"); } [Fact] public void Compress_lists_one_archive_job() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\a.txt", 1, T0) .File(@"C:\src\b.txt", 2, T0) .Dir(@"C:\dst"); var plan = Build(new OperationProfile { Name = "Archive", DestPath = @"C:\dst", DoCompress = true }, fs, [@"C:\src"]); Assert.True(plan.CanEnqueue); var compress = Assert.Single(plan.Operations); Assert.Equal(TransferOp.Compress, compress.Op); Assert.Equal(@"C:\src\a.txt|C:\src\b.txt", compress.SourcePath); Assert.Equal(@"C:\dst\src.7z", compress.DestinationPath); Assert.Contains(plan.ProfilePreview, r => r.Action == "Compress"); } [Fact] public void Dirty_git_stops_before_operations() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\a.txt", 1, T0) .Dir(@"C:\dst"); var git = new GitStatus { RepoRoot = @"C:\src", Branch = "main", ModifiedCount = 1 }; var plan = Build(CopyProfile(git: true), fs, [@"C:\src"], git); Assert.False(plan.CanEnqueue); Assert.Empty(plan.Operations); Assert.Contains(plan.Issues, i => i.Message == "Working tree is not clean."); } [Fact] public void Missing_git_and_missing_repo_are_errors() { var fs = Tree().Dir(@"C:\src").File(@"C:\src\a.txt", 1, T0).Dir(@"C:\dst"); var noGit = Build(CopyProfile(git: true), fs, [@"C:\src"], gitAvailable: false); Assert.Contains(noGit.Issues, i => i.Message == "git.exe is not available."); var noRepo = Build(CopyProfile(git: true), fs, [@"C:\src"], git: null); Assert.Contains(noRepo.Issues, i => i.Message == "Source is not a Git repository."); } [Fact] public void Offline_destination_is_an_error() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\a.txt", 1, T0) .Dir(@"Z:\backup"); var plan = Build( CopyProfile(@"Z:\backup"), fs, [@"C:\src"], reachable: path => path.StartsWith(@"C:\", StringComparison.OrdinalIgnoreCase)); Assert.False(plan.CanEnqueue); Assert.Empty(plan.Operations); Assert.Contains(plan.Issues, i => i.Message == "Destination is not available."); } [Fact] public void Rename_then_copy_uses_new_names() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\keep.txt", 1, T0) .File(@"C:\src\a.txt", 1, T0) .Dir(@"C:\dst"); var plan = Build(new OperationProfile { Name = "Rename copy", DestPath = @"C:\dst", DoCopy = true, DoRename = true, RenamePrefix = "x_" }, fs, [@"C:\src"]); Assert.True(plan.CanEnqueue); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Rename && o.NewName == "x_keep.txt"); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\x_a.txt" && o.DestinationPath == @"C:\dst\src\x_a.txt"); Assert.Contains(plan.Operations, o => o.Op == TransferOp.Copy && o.SourcePath == @"C:\src\x_keep.txt" && o.DestinationPath == @"C:\dst\src\x_keep.txt"); } [Fact] public void Excludes_and_online_only_files_are_skipped() { var fs = Tree() .Dir(@"C:\src") .File(@"C:\src\keep.txt", 1, T0) .File(@"C:\src\skip.tmp", 1, T0) .Dir(@"C:\src\bin") .File(@"C:\src\cloud.bin", 1, T0) .Dir(@"C:\dst"); var plan = Build(new OperationProfile { Name = "Copy", DestPath = @"C:\dst", DoCopy = true, Excludes = "*.tmp\nbin" }, fs, [@"C:\src"], wouldHydrate: item => item.Name == "cloud.bin"); Assert.Contains(plan.Operations, o => o.SourcePath.EndsWith("keep.txt", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("skip.tmp", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.Contains(@"\bin", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(plan.Operations, o => o.SourcePath.EndsWith("cloud.bin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(plan.Issues, i => i.Message.Contains("Online-only", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Missing_7zip_is_an_error() { var fs = Tree().Dir(@"C:\src").File(@"C:\src\a.txt", 1, T0).Dir(@"C:\dst"); var plan = Build(new OperationProfile { Name = "Archive", DestPath = @"C:\dst", DoCompress = true }, fs, [@"C:\src"], compressAvailable: false); Assert.False(plan.CanEnqueue); 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() { Name = "Copy", DestPath = dest, DoCopy = true, RequireGitClean = git }; private static OperationPlan Build( OperationProfile profile, IFileSystemEnumerator fs, string[] sources, GitStatus? git = null, bool gitAvailable = true, bool compressAvailable = true, bool convertAvailable = true, Func? reachable = null, Func? wouldHydrate = null) => new FileOperationProfilePlanner(new RenamePlanner()).Build( profile, sources, fs, reachable ?? (_ => true), git, gitAvailable, compressAvailable, SevenZipLocator.MissingHint, pathExists: _ => false, wouldHydrate: wouldHydrate, convertAvailable: convertAvailable, convertMissingHint: FfmpegLocator.MissingHint); private static TreeEnumerator Tree() => new(); }