2026-10-22 21:07:11 +08:00
|
|
|
using System.IO;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
|
|
|
|
|
using SongTidy.Models;
|
|
|
|
|
using SongTidy.Services;
|
2026-10-22 21:24:13 +08:00
|
|
|
using SongTidy.Tests.TestUtilities;
|
2026-10-22 21:07:11 +08:00
|
|
|
|
2026-10-22 21:24:13 +08:00
|
|
|
namespace SongTidy.Tests.Services;
|
2026-10-22 21:07:11 +08:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Contains tests for <see cref="RenameValidator"/>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestClass]
|
|
|
|
|
public sealed class RenameValidatorTests {
|
2026-10-22 21:24:13 +08:00
|
|
|
private readonly TempDirectory _tempDirectory = new();
|
2026-10-22 21:07:11 +08:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Removes the temporary directory created for the current test.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestCleanup]
|
|
|
|
|
public void Cleanup() {
|
2026-10-22 21:24:13 +08:00
|
|
|
_tempDirectory.Dispose();
|
2026-10-22 21:07:11 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#region Per-entry validation
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that empty and whitespace-only titles are rejected.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="title">The title to validate.</param>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("")]
|
|
|
|
|
[DataRow(" ")]
|
|
|
|
|
public void ReportsEmptyTitle(string title) {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, title));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.EmptyTitle, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that every forbidden file name character is detected in the title.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="invalidCharacter">The character to insert into the title.</param>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("\\")]
|
|
|
|
|
[DataRow("/")]
|
|
|
|
|
[DataRow(":")]
|
|
|
|
|
[DataRow("*")]
|
|
|
|
|
[DataRow("?")]
|
|
|
|
|
[DataRow("\"")]
|
|
|
|
|
[DataRow("<")]
|
|
|
|
|
[DataRow(">")]
|
|
|
|
|
[DataRow("|")]
|
|
|
|
|
[DataRow("\t")]
|
|
|
|
|
public void ReportsInvalidCharacterInTitle(string invalidCharacter) {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, "A" + invalidCharacter + "B"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.InvalidFieldCharacter, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that invalid characters are detected in the artist and comment fields.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReportsInvalidCharactersInArtistAndComment() {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(
|
|
|
|
|
CreateItem(sourcePath, "Title", artist: "A\\B", comment: "C:D"));
|
|
|
|
|
|
|
|
|
|
Assert.HasCount(
|
|
|
|
|
2,
|
|
|
|
|
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.InvalidFieldCharacter));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that target names ending with a dot or a space are rejected.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="title">The title that produces the invalid target ending.</param>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
[DataRow("Title.")]
|
|
|
|
|
[DataRow("Title ")]
|
|
|
|
|
public void ReportsInvalidTargetEnding(string title) {
|
|
|
|
|
string sourcePath = CreateFile("Source");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, title));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.InvalidTargetEnding, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that missing source files are detected.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReportsMissingSourceFile() {
|
2026-10-22 21:24:13 +08:00
|
|
|
string sourcePath = Path.Combine(_tempDirectory.FullPath, "Missing.mp3");
|
2026-10-22 21:07:11 +08:00
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, "Title"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.MissingSourceFile, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Source path validation
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that duplicate source paths are reported for every affected row.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReportsDuplicateSourcePaths() {
|
|
|
|
|
string sourcePath = CreateFile("Dup.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(
|
|
|
|
|
CreateItem(sourcePath, "One"),
|
|
|
|
|
CreateItem(sourcePath, "Two"));
|
|
|
|
|
|
|
|
|
|
Assert.HasCount(
|
|
|
|
|
2,
|
|
|
|
|
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.DuplicateSourcePath));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 1));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that source path comparison is case-sensitive.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void TreatsSourcePathsAsCaseSensitive() {
|
|
|
|
|
CreateFile("Case.mp3");
|
2026-10-22 21:24:13 +08:00
|
|
|
PlanItem first = CreateItem(Path.Combine(_tempDirectory.FullPath, "Case.mp3"), "One");
|
|
|
|
|
PlanItem second = CreateItem(Path.Combine(_tempDirectory.FullPath, "case.mp3"), "Two");
|
2026-10-22 21:07:11 +08:00
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(first, second);
|
|
|
|
|
|
|
|
|
|
Assert.IsFalse(
|
|
|
|
|
result.Issues.Any(issue => issue.Problem == RenameValidationProblem.DuplicateSourcePath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Target conflict validation
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that duplicate target paths are detected case-insensitively.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReportsDuplicateTargetNamesCaseInsensitively() {
|
|
|
|
|
string firstPath = CreateFile("A.mp3");
|
|
|
|
|
string secondPath = CreateFile("B.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(
|
|
|
|
|
CreateItem(firstPath, "Same"),
|
|
|
|
|
CreateItem(secondPath, "same"));
|
|
|
|
|
|
|
|
|
|
Assert.HasCount(
|
|
|
|
|
2,
|
|
|
|
|
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.DuplicateTargetName));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateTargetName, 1));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateTargetName, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that equal target names in different directories do not conflict.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void AllowsSameTargetNameInDifferentDirectories() {
|
|
|
|
|
string firstPath = CreateFile(Path.Combine("dir1", "A.mp3"));
|
|
|
|
|
string secondPath = CreateFile(Path.Combine("dir2", "B.mp3"));
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(
|
|
|
|
|
CreateItem(firstPath, "Same"),
|
|
|
|
|
CreateItem(secondPath, "Same"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(result.IsValid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that an existing target file is reported as a conflict.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReportsTargetFileConflict() {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
CreateFile("Existing.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, "Existing"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that a target equal to the entry's own source is not a conflict.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void AllowsTargetEqualToOwnSource() {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, "A"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(result.IsValid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that casing-only changes to the entry's own source are not conflicts.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void AllowsTargetEqualToOwnSourceIgnoringCase() {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, "a"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(result.IsValid);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that a renaming chain such as A to B and B to A is blocked.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void BlocksConflictChain() {
|
|
|
|
|
string aPath = CreateFile("A.mp3");
|
|
|
|
|
string bPath = CreateFile("B.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(
|
|
|
|
|
CreateItem(aPath, "B"),
|
|
|
|
|
CreateItem(bPath, "A"));
|
|
|
|
|
|
|
|
|
|
Assert.HasCount(
|
|
|
|
|
2,
|
|
|
|
|
result.Issues.Where(issue => issue.Problem == RenameValidationProblem.TargetFileConflict));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that the original file extension is preserved when checking conflicts.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void UsesOriginalExtensionForTargetNames() {
|
|
|
|
|
string sourcePath = CreateFile("Source.flac");
|
|
|
|
|
CreateFile("Title.flac");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(CreateItem(sourcePath, "Title"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that the selected naming format controls the generated target name.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void UsesSelectedFormatForTargetNames() {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
CreateFile("Title - Artist.mp3");
|
|
|
|
|
PlanItem item = CreateItem(sourcePath, "Title", artist: "Artist");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = RenameValidator.Validate(
|
|
|
|
|
new[] { item },
|
|
|
|
|
RenameFormat.TitleFirst);
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.TargetFileConflict, 1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
#region Combined validation
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that validation collects problems from multiple rules in one pass.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReportsAllProblemKinds() {
|
|
|
|
|
string sourcePath = CreateFile("A.mp3");
|
|
|
|
|
PlanItem emptyTitle = CreateItem(sourcePath, "");
|
2026-10-22 21:24:13 +08:00
|
|
|
PlanItem missingSource = CreateItem(Path.Combine(_tempDirectory.FullPath, "Missing.mp3"), "Title");
|
2026-10-22 21:07:11 +08:00
|
|
|
PlanItem invalidCharacter = CreateItem(sourcePath, "Bad/Title");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(emptyTitle, missingSource, invalidCharacter);
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.EmptyTitle, 1));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.MissingSourceFile, 2));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.InvalidFieldCharacter, 3));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 1));
|
|
|
|
|
Assert.IsTrue(HasProblem(result, RenameValidationProblem.DuplicateSourcePath, 3));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Verifies that a clean set of entries produces no issues.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[TestMethod]
|
|
|
|
|
public void ReturnsValidResultForRenameableEntries() {
|
|
|
|
|
string firstPath = CreateFile("A.mp3");
|
|
|
|
|
string secondPath = CreateFile("B.mp3");
|
|
|
|
|
|
|
|
|
|
RenameValidationResult result = Validate(
|
|
|
|
|
CreateItem(firstPath, "One"),
|
|
|
|
|
CreateItem(secondPath, "Two"));
|
|
|
|
|
|
|
|
|
|
Assert.IsTrue(result.IsValid);
|
|
|
|
|
Assert.HasCount(0, result.Issues);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates an empty file inside the test directory.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="fileName">The relative file name, which may include a subdirectory.</param>
|
|
|
|
|
/// <returns>The full path of the created file.</returns>
|
|
|
|
|
private string CreateFile(string fileName) {
|
2026-10-22 21:24:13 +08:00
|
|
|
string path = Path.Combine(_tempDirectory.FullPath, fileName);
|
|
|
|
|
string directory = Path.GetDirectoryName(path) ?? _tempDirectory.FullPath;
|
2026-10-22 21:07:11 +08:00
|
|
|
|
|
|
|
|
Directory.CreateDirectory(directory);
|
|
|
|
|
File.WriteAllText(path, string.Empty);
|
|
|
|
|
|
|
|
|
|
return path;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Creates a plan item with the supplied field values.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="path">The source file path.</param>
|
|
|
|
|
/// <param name="title">The title value.</param>
|
|
|
|
|
/// <param name="artist">The artist value.</param>
|
|
|
|
|
/// <param name="comment">The comment value.</param>
|
|
|
|
|
/// <returns>The created plan item.</returns>
|
|
|
|
|
private static PlanItem CreateItem(string path, string title, string artist = "", string comment = "") {
|
|
|
|
|
return new PlanItem {
|
|
|
|
|
Path = path,
|
|
|
|
|
Artist = artist,
|
|
|
|
|
Title = title,
|
|
|
|
|
Comment = comment,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Validates entries with the artist-first format.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="items">The entries to validate.</param>
|
|
|
|
|
/// <returns>The validation result.</returns>
|
|
|
|
|
private static RenameValidationResult Validate(params PlanItem[] items) {
|
|
|
|
|
return RenameValidator.Validate(items, RenameFormat.ArtistFirst);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Determines whether the result contains a specific problem for a row.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="result">The validation result to inspect.</param>
|
|
|
|
|
/// <param name="problem">The problem category to find.</param>
|
|
|
|
|
/// <param name="rowNumber">The one-based row number to match.</param>
|
|
|
|
|
/// <returns><see langword="true"/> when the problem is present.</returns>
|
|
|
|
|
private static bool HasProblem(
|
|
|
|
|
RenameValidationResult result,
|
|
|
|
|
RenameValidationProblem problem,
|
|
|
|
|
int rowNumber) {
|
|
|
|
|
return result.Issues.Any(issue => issue.Problem == problem && issue.RowNumber == rowNumber);
|
|
|
|
|
}
|
|
|
|
|
}
|