using System.IO; using System.Linq; using SongTidy.Models; using SongTidy.Services; namespace SongTidy.Tests; /// /// Contains tests for . /// [TestClass] public sealed class RenameValidatorTests { private string _tempDirectory = string.Empty; /// /// Creates a unique temporary directory for the current test. /// [TestInitialize] public void Initialize() { _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Validation {Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDirectory); } /// /// Removes the temporary directory created for the current test. /// [TestCleanup] public void Cleanup() { Directory.Delete(_tempDirectory, recursive: true); } #region Per-entry validation /// /// Verifies that empty and whitespace-only titles are rejected. /// /// The title to validate. [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)); } /// /// Verifies that every forbidden file name character is detected in the title. /// /// The character to insert into the title. [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)); } /// /// Verifies that invalid characters are detected in the artist and comment fields. /// [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)); } /// /// Verifies that target names ending with a dot or a space are rejected. /// /// The title that produces the invalid target ending. [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)); } /// /// Verifies that missing source files are detected. /// [TestMethod] public void ReportsMissingSourceFile() { string sourcePath = Path.Combine(_tempDirectory, "Missing.mp3"); RenameValidationResult result = Validate(CreateItem(sourcePath, "Title")); Assert.IsTrue(HasProblem(result, RenameValidationProblem.MissingSourceFile, 1)); } #endregion #region Source path validation /// /// Verifies that duplicate source paths are reported for every affected row. /// [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)); } /// /// Verifies that source path comparison is case-sensitive. /// [TestMethod] public void TreatsSourcePathsAsCaseSensitive() { CreateFile("Case.mp3"); PlanItem first = CreateItem(Path.Combine(_tempDirectory, "Case.mp3"), "One"); PlanItem second = CreateItem(Path.Combine(_tempDirectory, "case.mp3"), "Two"); RenameValidationResult result = Validate(first, second); Assert.IsFalse( result.Issues.Any(issue => issue.Problem == RenameValidationProblem.DuplicateSourcePath)); } #endregion #region Target conflict validation /// /// Verifies that duplicate target paths are detected case-insensitively. /// [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)); } /// /// Verifies that equal target names in different directories do not conflict. /// [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); } /// /// Verifies that an existing target file is reported as a conflict. /// [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)); } /// /// Verifies that a target equal to the entry's own source is not a conflict. /// [TestMethod] public void AllowsTargetEqualToOwnSource() { string sourcePath = CreateFile("A.mp3"); RenameValidationResult result = Validate(CreateItem(sourcePath, "A")); Assert.IsTrue(result.IsValid); } /// /// Verifies that casing-only changes to the entry's own source are not conflicts. /// [TestMethod] public void AllowsTargetEqualToOwnSourceIgnoringCase() { string sourcePath = CreateFile("A.mp3"); RenameValidationResult result = Validate(CreateItem(sourcePath, "a")); Assert.IsTrue(result.IsValid); } /// /// Verifies that a renaming chain such as A to B and B to A is blocked. /// [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)); } /// /// Verifies that the original file extension is preserved when checking conflicts. /// [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)); } /// /// Verifies that the selected naming format controls the generated target name. /// [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 /// /// Verifies that validation collects problems from multiple rules in one pass. /// [TestMethod] public void ReportsAllProblemKinds() { string sourcePath = CreateFile("A.mp3"); PlanItem emptyTitle = CreateItem(sourcePath, ""); PlanItem missingSource = CreateItem(Path.Combine(_tempDirectory, "Missing.mp3"), "Title"); 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)); } /// /// Verifies that a clean set of entries produces no issues. /// [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 /// /// Creates an empty file inside the test directory. /// /// The relative file name, which may include a subdirectory. /// The full path of the created file. private string CreateFile(string fileName) { string path = Path.Combine(_tempDirectory, fileName); string directory = Path.GetDirectoryName(path) ?? _tempDirectory; Directory.CreateDirectory(directory); File.WriteAllText(path, string.Empty); return path; } /// /// Creates a plan item with the supplied field values. /// /// The source file path. /// The title value. /// The artist value. /// The comment value. /// The created plan item. private static PlanItem CreateItem(string path, string title, string artist = "", string comment = "") { return new PlanItem { Path = path, Artist = artist, Title = title, Comment = comment, }; } /// /// Validates entries with the artist-first format. /// /// The entries to validate. /// The validation result. private static RenameValidationResult Validate(params PlanItem[] items) { return RenameValidator.Validate(items, RenameFormat.ArtistFirst); } /// /// Determines whether the result contains a specific problem for a row. /// /// The validation result to inspect. /// The problem category to find. /// The one-based row number to match. /// when the problem is present. private static bool HasProblem( RenameValidationResult result, RenameValidationProblem problem, int rowNumber) { return result.Issues.Any(issue => issue.Problem == problem && issue.RowNumber == rowNumber); } }