Add Git overlay, operation tools, and virtualized preview so large folders stay responsive.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 15:04:04 +02:00
parent 9bf451932f
commit a3c54bbb03
127 changed files with 14748 additions and 633 deletions

View File

@@ -0,0 +1,187 @@
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);
Assert.Contains("{Width}", new RenamePlanner().Build(
[File(@"C:\a\clip.mp4")],
new RenameRuleSet { Prefix = "{Width}_" }).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<PlanIssue>();
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<PlanIssue>();
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<string>(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<string>(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<string>(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));
}
}