using Explorer.Application; using Explorer.Domain; namespace Explorer.Application.Tests; public class RenamePlannerTests { private static RenameSubject File(string path) => new(path, PathRules.GetFileName(path), false); [Fact] public void Search_replace_prefix_suffix_and_counter() { var plan = new RenamePlanner().Build( [File(@"C:\a\Vacation.jpg"), File(@"C:\a\Vacation (2).jpg")], new RenameRuleSet { Search = "Vacation", Replace = "Trip", Prefix = "2024_", Suffix = "_ok", UseCounter = true, CounterStart = 1, CounterPadding = 2 }); Assert.True(plan.CanEnqueue); Assert.Equal("2024_Trip_ok01.jpg", plan.Preview[0].NewName); Assert.Equal("2024_Trip (2)_ok02.jpg", plan.Preview[1].NewName); } [Fact] public void Regex_replace_and_case_and_extension() { var plan = new RenamePlanner().Build( [File(@"C:\a\IMG_001.JPG")], new RenameRuleSet { Search = @"IMG_(\d+)", Replace = "Photo_$1", UseRegex = true, CaseMode = RenameCaseMode.Lower, ChangeExtension = true, NewExtension = "jpg" }); Assert.Equal("photo_001.jpg", plan.Preview[0].NewName); } [Fact] public void Counter_placeholder_does_not_need_hydration() { var plan = new RenamePlanner().Build( [File(@"C:\a\clip.mp4")], new RenameRuleSet { Prefix = "Clip_{Counter}_", UseCounter = true, CounterPadding = 3 }); Assert.Equal("Clip_001_clip.mp4", plan.Preview[0].NewName); } [Fact] public void Media_placeholders_use_supplied_tags() { var plan = new RenamePlanner().Build( [File(@"C:\a\clip.mp4")], new RenameRuleSet { Prefix = "{Width}_", NamePattern = "{Artist} - {Title}" }, tagsByPath: new Dictionary(StringComparer.OrdinalIgnoreCase) { [@"C:\a\clip.mp4"] = new() { Width = 1920, Artist = "Queen", Title = "Bohemian Rhapsody", Stem = "clip", Extension = "mp4" } }); Assert.Equal("1920_Queen - Bohemian Rhapsody.mp4", plan.Preview[0].NewName); } [Fact] public void Online_only_media_placeholder_is_an_error() { var plan = new RenamePlanner().Build( [File(@"C:\cloud\clip.mp4")], new RenameRuleSet { Prefix = "{Artist}_" }, tagsByPath: new Dictionary(StringComparer.OrdinalIgnoreCase) { [@"C:\cloud\clip.mp4"] = new() { HydrationBlocked = true, Stem = "clip", Extension = "mp4" } }); Assert.False(plan.CanEnqueue); Assert.Contains(plan.Issues, i => i.Message.Contains("online-only", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Project_and_taken_placeholders_expand() { var plan = new RenamePlanner().Build( [File(@"C:\src\Explorer\photos\DSC_001.jpg")], new RenameRuleSet { NamePattern = "{Project}_{TakenDate:yyyyMMdd}_{Name}" }, tagsByPath: new Dictionary(StringComparer.OrdinalIgnoreCase) { [@"C:\src\Explorer\photos\DSC_001.jpg"] = new() { Project = "Explorer", Taken = new DateTimeOffset(new DateTime(2024, 8, 26, 0, 0, 0, DateTimeKind.Local)), Stem = "DSC_001", Extension = "jpg" } }); Assert.Equal("Explorer_20240826_DSC_001.jpg", plan.Preview[0].NewName); } [Fact] public void Invalid_windows_name_is_an_error() { var plan = new RenamePlanner().Build( [File(@"C:\a\notes.txt")], new RenameRuleSet { Search = "notes", Replace = "CON" }); Assert.False(plan.CanEnqueue); Assert.Contains(plan.Issues, i => i.Severity == PlanIssueSeverity.Error); } [Fact] public void Collision_when_two_files_map_to_the_same_name() { var plan = new RenamePlanner().Build( [File(@"C:\a\alpha.txt"), File(@"C:\a\beta.txt")], new RenameRuleSet { UseRegex = true, Search = ".+", Replace = "same" }); Assert.False(plan.CanEnqueue); Assert.Contains(plan.Issues, i => i.Message.Contains("same name", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Existing_path_outside_the_batch_is_a_collision() { var plan = new RenamePlanner().Build( [File(@"C:\a\one.txt")], new RenameRuleSet { Search = "one", Replace = "held" }, path => path.Equals(@"C:\a\held.txt", StringComparison.OrdinalIgnoreCase)); Assert.False(plan.CanEnqueue); Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Collision_with_an_unchanged_file_in_the_batch() { var plan = new RenamePlanner().Build( [File(@"C:\a\one.txt"), File(@"C:\a\held.txt")], new RenameRuleSet { Search = "one", Replace = "held" }); Assert.False(plan.CanEnqueue); Assert.Contains(plan.Issues, i => i.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Chain_is_ordered_so_the_destination_moves_first() { var issues = new List(); var ordered = RenamePlanner.Order( [ (@"C:\a\a.txt", @"C:\a\b.txt", "b.txt"), (@"C:\a\b.txt", @"C:\a\c.txt", "c.txt") ], issues); Assert.Empty(issues); Assert.Equal(@"C:\a\b.txt", ordered[0].Source); Assert.Equal(@"C:\a\a.txt", ordered[1].Source); } [Fact] public void Cycle_is_an_error() { var issues = new List(); RenamePlanner.Order( [ (@"C:\a\x.txt", @"C:\a\y.txt", "y.txt"), (@"C:\a\y.txt", @"C:\a\x.txt", "x.txt") ], issues); Assert.Contains(issues, i => i.Message.Contains("cycle", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Undo_restores_names_when_destination_still_matches() { var batch = new RenameBatch { Id = 1, CreatedUtc = DateTimeOffset.UtcNow, Items = [ new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0), new RenameBatchItem(@"C:\a\one.txt", @"C:\a\two.txt", 1) ] }; var exists = new HashSet(StringComparer.OrdinalIgnoreCase) { @"C:\a\new.txt", @"C:\a\two.txt" }; var plan = new RenamePlanner().BuildUndo(batch, exists.Contains); Assert.True(plan.CanEnqueue); Assert.Equal(@"C:\a\two.txt", plan.Operations[0].SourcePath); Assert.Equal(@"C:\a\old.txt", plan.Operations[1].DestinationPath); } [Fact] public void Undo_refuses_when_rename_has_not_finished() { var batch = new RenameBatch { Id = 1, CreatedUtc = DateTimeOffset.UtcNow, Items = [new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0)] }; var exists = new HashSet(StringComparer.OrdinalIgnoreCase) { @"C:\a\old.txt" }; var plan = new RenamePlanner().BuildUndo(batch, exists.Contains); Assert.True(plan.HasErrors); Assert.Contains(plan.Issues, i => i.Message.Contains("not finished", StringComparison.OrdinalIgnoreCase)); } [Fact] public void Undo_fails_when_original_name_is_taken() { var batch = new RenameBatch { Id = 1, CreatedUtc = DateTimeOffset.UtcNow, Items = [new RenameBatchItem(@"C:\a\old.txt", @"C:\a\new.txt", 0)] }; var exists = new HashSet(StringComparer.OrdinalIgnoreCase) { @"C:\a\new.txt", @"C:\a\old.txt" }; var plan = new RenamePlanner().BuildUndo(batch, exists.Contains); Assert.True(plan.HasErrors); } [Fact] public void Invalid_regex_is_an_error() { var plan = new RenamePlanner().Build( [File(@"C:\a\a.txt")], new RenameRuleSet { UseRegex = true, Search = "(" }); Assert.False(plan.CanEnqueue); Assert.Contains(plan.Issues, i => i.Message.Contains("regular expression", StringComparison.OrdinalIgnoreCase)); } }