refactor: organize test project structure
This commit is contained in:
1 parent
996c0a9515
commit
c6d16fd108
8 files changed
+106
-94
No files matched your search
@@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using SongTidy.Services;
|
||||
using SongTidy.Tests.TestUtilities;
|
||||
|
||||
namespace SongTidy.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="AudioFileScanner"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class AudioFileScannerTests {
|
||||
private readonly TempDirectory _tempDirectory = new();
|
||||
|
||||
/// <summary>
|
||||
/// Removes the temporary directory created for the current test.
|
||||
/// </summary>
|
||||
[TestCleanup]
|
||||
public void Cleanup() {
|
||||
_tempDirectory.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that supported extensions are matched case-insensitively and others are ignored.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ScansSupportedExtensionsCaseInsensitively() {
|
||||
CreateFile("a.mp3");
|
||||
CreateFile("b.FLAC");
|
||||
CreateFile("c.txt");
|
||||
|
||||
IReadOnlyList<string> files = AudioFileScanner.Scan(_tempDirectory.FullPath);
|
||||
|
||||
Assert.HasCount(2, files);
|
||||
Assert.IsTrue(files.Any(file => file.EndsWith("a.mp3", StringComparison.OrdinalIgnoreCase)));
|
||||
Assert.IsTrue(files.Any(file => file.EndsWith("b.FLAC", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that subdirectories are not scanned.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoesNotScanSubdirectories() {
|
||||
CreateFile("root.mp3");
|
||||
CreateFile(Path.Combine("sub", "nested.wav"));
|
||||
|
||||
IReadOnlyList<string> files = AudioFileScanner.Scan(_tempDirectory.FullPath);
|
||||
|
||||
Assert.HasCount(1, files);
|
||||
Assert.IsTrue(files[0].EndsWith("root.mp3", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an empty file inside the test directory.
|
||||
/// </summary>
|
||||
/// <param name="fileName">The relative file name, which may include a subdirectory.</param>
|
||||
private void CreateFile(string fileName) {
|
||||
string path = Path.Combine(_tempDirectory.FullPath, fileName);
|
||||
string directory = Path.GetDirectoryName(path) ?? _tempDirectory.FullPath;
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(path, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using SongTidy.Services;
|
||||
|
||||
namespace SongTidy.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="FilenameParser"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class FilenameParserTests {
|
||||
#region Successful parsing
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the documented example with one English and one Chinese bracket group.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ParsesSpecificationExample() {
|
||||
AssertParse("A - B (Live - Tokyo)(修正版)", "A", "B", "Live - Tokyo 修正版");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple comment groups are joined with a single half-width space.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ParsesMultipleCommentGroups() {
|
||||
AssertParse("A - B (Live)(Remix)", "A", "B", "Live Remix");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that Chinese bracket groups are extracted like English bracket groups.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ParsesChineseBracketComment() {
|
||||
AssertParse("歌手 - 歌名(现场)", "歌手", "歌名", "现场");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that all comment groups are removed from the text used for artist and title.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void RemovesAllCommentGroupsFromSource() {
|
||||
AssertParse("A - B (Live) (Tokyo)", "A", "B", "Live Tokyo");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that nested brackets stay as literal text inside the comment.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void KeepsNestedBracketsInsideComment() {
|
||||
AssertParse("A - B (Live (Tokyo) Mix)", "A", "B", "Live (Tokyo) Mix");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that nested full-width brackets stay as literal text inside an English comment.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void PreservesFullWidthNestedBrackets() {
|
||||
AssertParse("A - B (Live(东京))", "A", "B", "Live(东京)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that only the outermost pair of a nested group is treated as the comment.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void TreatsOnlyOutermostBracketGroupAsComment() {
|
||||
AssertParse("A - B ((Live))", "A", "B", "(Live)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a comment can be extracted from a name without a hyphen.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ParsesCommentWithoutArtistSeparator() {
|
||||
AssertParse("Song (Live)", string.Empty, "Song", "Live");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Invalid bracket structures
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an unmatched opening bracket disables comment extraction entirely.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForUnmatchedOpeningBracket() {
|
||||
AssertParse("A - B (Live", "A", "B (Live", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an unmatched closing bracket disables comment extraction entirely.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForUnmatchedClosingBracket() {
|
||||
AssertParse("A - B Live)", "A", "B Live)", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that opening and closing brackets of different types cannot pair.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForMismatchedBracketTypes() {
|
||||
AssertParse("A - B (Live)", "A", "B (Live)", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an extra closing bracket after a valid pair disables extraction entirely.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForExtraClosingBracket() {
|
||||
AssertParse("A - B (Live))", "A", "B (Live))", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an extra opening bracket after a valid pair disables extraction entirely.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForExtraOpeningBracket() {
|
||||
AssertParse("A - B (Live) (", "A", "B (Live) (", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an unclosed outer bracket invalidates otherwise complete nested brackets.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForUnclosedOuterBracket() {
|
||||
AssertParse("A - B (Live (Tokyo)", "A", "B (Live (Tokyo)", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that crossing bracket groups are treated as an invalid structure.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void AbandonsExtractionForCrossingBracketGroups() {
|
||||
AssertParse("(a(b))", string.Empty, "(a(b))", string.Empty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Hyphen handling
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a single half-width hyphen separates artist from title.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SplitsSingleHalfWidthHyphen() {
|
||||
AssertParse("A - B", "A", "B", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that multiple half-width hyphens make the whole remaining text the title.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void TreatsMultipleHyphensAsTitleOnly() {
|
||||
AssertParse("A - B - C", string.Empty, "A - B - C", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that hyphens inside a removed comment do not count when applying the multiple-hyphen rule.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IgnoresHyphensInsideCommentsWhenSplitting() {
|
||||
AssertParse("A - B (Live - Tokyo - Mix)", "A", "B", "Live - Tokyo - Mix");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a full-width hyphen is not treated as a separator.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoesNotSplitOnFullWidthHyphen() {
|
||||
AssertParse("A - B", string.Empty, "A - B", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an em dash is not treated as a separator.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoesNotSplitOnEmDash() {
|
||||
AssertParse("A — B", string.Empty, "A — B", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a name without a hyphen produces an empty artist and a full title.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReturnsEmptyArtistWhenNoHyphenExists() {
|
||||
AssertParse("Song Title", string.Empty, "Song Title", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an empty input produces three empty fields.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ReturnsEmptyFieldsForEmptyInput() {
|
||||
AssertParse(string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Whitespace handling
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that standard whitespace around the artist and title is removed.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void TrimsStandardWhitespaceAroundArtistAndTitle() {
|
||||
AssertParse(" A - B ", "A", "B", string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that standard whitespace around extracted comment text is removed.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void TrimsStandardWhitespaceInsideComments() {
|
||||
AssertParse("A - B ( Live )", "A", "B", "Live");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that full-width spaces inside comment text are preserved.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void PreservesFullWidthSpacesInsideComments() {
|
||||
AssertParse("A - B (\u3000Live\u3000)", "A", "B", "\u3000Live\u3000");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that full-width spaces around artist and title are preserved.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void PreservesFullWidthSpacesAroundFields() {
|
||||
AssertParse("A\u3000-\u3000B", "A\u3000", "\u3000B", string.Empty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Parses the input and asserts all three resulting fields.
|
||||
/// </summary>
|
||||
/// <param name="input">The file name to parse.</param>
|
||||
/// <param name="expectedArtist">The expected artist value.</param>
|
||||
/// <param name="expectedTitle">The expected title value.</param>
|
||||
/// <param name="expectedComment">The expected comment value.</param>
|
||||
private static void AssertParse(
|
||||
string input,
|
||||
string expectedArtist,
|
||||
string expectedTitle,
|
||||
string expectedComment) {
|
||||
FilenameParseResult result = FilenameParser.Parse(input);
|
||||
|
||||
Assert.AreEqual(expectedArtist, result.Artist, "Artist mismatch.");
|
||||
Assert.AreEqual(expectedTitle, result.Title, "Title mismatch.");
|
||||
Assert.AreEqual(expectedComment, result.Comment, "Comment mismatch.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml.Linq;
|
||||
|
||||
using SongTidy.Models;
|
||||
using SongTidy.Services;
|
||||
using SongTidy.Tests.TestUtilities;
|
||||
|
||||
namespace SongTidy.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="PlanXmlIO"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class PlanXmlIOTests {
|
||||
#region Document structure
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the generated XML root, version, format, and item attributes.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SavesExpectedDocumentStructure() {
|
||||
SongPlan plan = CreateSamplePlan();
|
||||
|
||||
string xml = SaveToString(plan);
|
||||
XDocument document = XDocument.Parse(xml);
|
||||
XElement root = document.Root!;
|
||||
|
||||
Assert.AreEqual("Plan", root.Name.LocalName);
|
||||
Assert.AreEqual(SongPlan.CurrentVersion.ToString(CultureInfo.InvariantCulture), root.Attribute("version")?.Value);
|
||||
Assert.HasCount(1, root.Attributes());
|
||||
Assert.AreEqual("ArtistFirst", root.Element("Format")?.Value);
|
||||
|
||||
XElement items = root.Element("Items")!;
|
||||
Assert.HasCount(2, items.Elements("Item"));
|
||||
|
||||
XElement first = items.Elements("Item").First();
|
||||
Assert.AreEqual(@"D:\Music\A - B (Live).mp3", first.Attribute("Path")?.Value);
|
||||
Assert.AreEqual("A", first.Attribute("Artist")?.Value);
|
||||
Assert.AreEqual("B", first.Attribute("Title")?.Value);
|
||||
Assert.AreEqual("Live", first.Attribute("Comment")?.Value);
|
||||
|
||||
Assert.IsTrue(
|
||||
xml.StartsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>", StringComparison.Ordinal),
|
||||
"The XML declaration should specify UTF-8.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that empty fields are still written as empty attributes.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void WritesEmptyFieldsAsAttributes() {
|
||||
SongPlan plan = new() {
|
||||
Items = new List<PlanItem> {
|
||||
new PlanItem {
|
||||
Path = @"C:\Music\C.mp3",
|
||||
Title = "C",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
string xml = SaveToString(plan);
|
||||
XElement item = XDocument.Parse(xml).Root!.Element("Items")!.Element("Item")!;
|
||||
|
||||
Assert.IsNotNull(item.Attribute("Artist"));
|
||||
Assert.AreEqual(string.Empty, item.Attribute("Artist")!.Value);
|
||||
Assert.IsNotNull(item.Attribute("Comment"));
|
||||
Assert.AreEqual(string.Empty, item.Attribute("Comment")!.Value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Round trips
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that both format enum values survive a round trip.
|
||||
/// </summary>
|
||||
/// <param name="format">The format to round-trip.</param>
|
||||
[TestMethod]
|
||||
[DataRow(RenameFormat.ArtistFirst)]
|
||||
[DataRow(RenameFormat.TitleFirst)]
|
||||
public void RoundTripsFormat(RenameFormat format) {
|
||||
SongPlan plan = new() {
|
||||
Format = format,
|
||||
Items = new List<PlanItem> {
|
||||
new PlanItem { Path = @"C:\Music\C.mp3", Title = "C" },
|
||||
},
|
||||
};
|
||||
|
||||
SongPlan loaded = RoundTrip(plan);
|
||||
|
||||
Assert.AreEqual(format, loaded.Format);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that every field and the version survive a round trip.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void RoundTripsAllFields() {
|
||||
SongPlan plan = CreateSamplePlan();
|
||||
|
||||
SongPlan loaded = RoundTrip(plan);
|
||||
|
||||
Assert.AreEqual(plan.Version, loaded.Version);
|
||||
Assert.AreEqual(plan.Format, loaded.Format);
|
||||
Assert.HasCount(2, loaded.Items);
|
||||
AssertPlanItem(plan.Items[0], loaded.Items[0]);
|
||||
AssertPlanItem(plan.Items[1], loaded.Items[1]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a non-default version attribute is preserved.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void PreservesVersion() {
|
||||
SongPlan plan = CreateSamplePlan();
|
||||
int version = SongPlan.CurrentVersion + 1;
|
||||
plan.Version = version;
|
||||
|
||||
SongPlan loaded = RoundTrip(plan);
|
||||
|
||||
Assert.AreEqual(version, loaded.Version);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that paths and fields containing spaces and XML special characters round-trip.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void RoundTripsPathsWithSpacesAndSpecialCharacters() {
|
||||
const string path = @"D:\Music\A & B ""quoted"" <tag> 'single' - (Live).mp3";
|
||||
SongPlan plan = new() {
|
||||
Items = new List<PlanItem> {
|
||||
new PlanItem {
|
||||
Path = path,
|
||||
Artist = "A & B",
|
||||
Title = "Title <tag>",
|
||||
Comment = "\"Comment\"",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
string xml = SaveToString(plan);
|
||||
|
||||
Assert.IsTrue(xml.Contains("&", StringComparison.Ordinal));
|
||||
Assert.IsTrue(xml.Contains(""", StringComparison.Ordinal));
|
||||
Assert.IsTrue(xml.Contains("<", StringComparison.Ordinal));
|
||||
Assert.IsTrue(xml.Contains(">", StringComparison.Ordinal));
|
||||
|
||||
SongPlan loaded = RoundTrip(plan);
|
||||
|
||||
Assert.AreEqual(path, loaded.Items[0].Path);
|
||||
Assert.AreEqual("A & B", loaded.Items[0].Artist);
|
||||
Assert.AreEqual("Title <tag>", loaded.Items[0].Title);
|
||||
Assert.AreEqual("\"Comment\"", loaded.Items[0].Comment);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Loading
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that missing optional attributes are loaded as empty strings.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void LoadsMissingOptionalAttributesAsEmpty() {
|
||||
string xml = $"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Plan version="{SongPlan.CurrentVersion}">
|
||||
<Format>TitleFirst</Format>
|
||||
<Items>
|
||||
<Item Path="C:\Music\C.mp3" Title="C" />
|
||||
</Items>
|
||||
</Plan>
|
||||
""";
|
||||
|
||||
SongPlan plan = LoadFromString(xml);
|
||||
|
||||
Assert.AreEqual(RenameFormat.TitleFirst, plan.Format);
|
||||
Assert.HasCount(1, plan.Items);
|
||||
Assert.AreEqual(@"C:\Music\C.mp3", plan.Items[0].Path);
|
||||
Assert.AreEqual(string.Empty, plan.Items[0].Artist);
|
||||
Assert.AreEqual("C", plan.Items[0].Title);
|
||||
Assert.AreEqual(string.Empty, plan.Items[0].Comment);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a missing Items element leaves an empty initialized list.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void LoadsMissingItemsAsEmptyList() {
|
||||
string xml = $"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Plan version="{SongPlan.CurrentVersion}">
|
||||
<Format>ArtistFirst</Format>
|
||||
</Plan>
|
||||
""";
|
||||
|
||||
SongPlan plan = LoadFromString(xml);
|
||||
|
||||
Assert.HasCount(0, plan.Items);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region File access
|
||||
|
||||
/// <summary>
|
||||
/// Verifies file save and load with spaces in the file path and UTF-8 output without a BOM.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void SavesAndLoadsFileWithSpacesInPath() {
|
||||
using TempDirectory tempDirectory = new();
|
||||
string filePath = Path.Combine(tempDirectory.FullPath, "plan with spaces.xml");
|
||||
|
||||
SongPlan plan = CreateSamplePlan();
|
||||
|
||||
PlanXmlIO.Save(plan, filePath);
|
||||
|
||||
Assert.IsTrue(File.Exists(filePath));
|
||||
|
||||
byte[] bytes = File.ReadAllBytes(filePath);
|
||||
Assert.AreEqual((byte)'<', bytes[0]);
|
||||
|
||||
string xml = Encoding.UTF8.GetString(bytes);
|
||||
Assert.IsTrue(
|
||||
xml.StartsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>", StringComparison.Ordinal),
|
||||
"The file should be written as UTF-8 without a BOM.");
|
||||
|
||||
SongPlan loaded = PlanXmlIO.Load(filePath);
|
||||
Assert.AreEqual(plan.Format, loaded.Format);
|
||||
Assert.HasCount(plan.Items.Count, loaded.Items);
|
||||
AssertPlanItem(plan.Items[0], loaded.Items[0]);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Creates a sample plan containing two entries.
|
||||
/// </summary>
|
||||
/// <returns>The sample plan.</returns>
|
||||
private static SongPlan CreateSamplePlan() {
|
||||
return new SongPlan {
|
||||
Format = RenameFormat.ArtistFirst,
|
||||
Items = new List<PlanItem> {
|
||||
new PlanItem {
|
||||
Path = @"D:\Music\A - B (Live).mp3",
|
||||
Artist = "A",
|
||||
Title = "B",
|
||||
Comment = "Live",
|
||||
},
|
||||
new PlanItem {
|
||||
Path = @"D:\Music\C.mp3",
|
||||
Artist = string.Empty,
|
||||
Title = "C",
|
||||
Comment = string.Empty,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a plan to an in-memory stream and returns the decoded XML.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to save.</param>
|
||||
/// <returns>The generated XML text.</returns>
|
||||
private static string SaveToString(SongPlan plan) {
|
||||
using MemoryStream stream = new();
|
||||
PlanXmlIO.Save(plan, stream);
|
||||
return Encoding.UTF8.GetString(stream.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a plan from XML text.
|
||||
/// </summary>
|
||||
/// <param name="xml">The XML text to load.</param>
|
||||
/// <returns>The loaded plan.</returns>
|
||||
private static SongPlan LoadFromString(string xml) {
|
||||
using MemoryStream stream = new(Encoding.UTF8.GetBytes(xml));
|
||||
return PlanXmlIO.Load(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves and reloads a plan through an in-memory stream.
|
||||
/// </summary>
|
||||
/// <param name="plan">The plan to round-trip.</param>
|
||||
/// <returns>The reloaded plan.</returns>
|
||||
private static SongPlan RoundTrip(SongPlan plan) {
|
||||
using MemoryStream stream = new();
|
||||
PlanXmlIO.Save(plan, stream);
|
||||
stream.Position = 0;
|
||||
return PlanXmlIO.Load(stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that two plan items contain the same field values.
|
||||
/// </summary>
|
||||
/// <param name="expected">The expected item.</param>
|
||||
/// <param name="actual">The actual item.</param>
|
||||
private static void AssertPlanItem(PlanItem expected, PlanItem actual) {
|
||||
Assert.AreEqual(expected.Path, actual.Path);
|
||||
Assert.AreEqual(expected.Artist, actual.Artist);
|
||||
Assert.AreEqual(expected.Title, actual.Title);
|
||||
Assert.AreEqual(expected.Comment, actual.Comment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
using SongTidy.Models;
|
||||
using SongTidy.Services;
|
||||
using SongTidy.Tests.TestUtilities;
|
||||
|
||||
namespace SongTidy.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for <see cref="RenameValidator"/>.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public sealed class RenameValidatorTests {
|
||||
private readonly TempDirectory _tempDirectory = new();
|
||||
|
||||
/// <summary>
|
||||
/// Removes the temporary directory created for the current test.
|
||||
/// </summary>
|
||||
[TestCleanup]
|
||||
public void Cleanup() {
|
||||
_tempDirectory.Dispose();
|
||||
}
|
||||
|
||||
#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() {
|
||||
string sourcePath = Path.Combine(_tempDirectory.FullPath, "Missing.mp3");
|
||||
|
||||
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");
|
||||
PlanItem first = CreateItem(Path.Combine(_tempDirectory.FullPath, "Case.mp3"), "One");
|
||||
PlanItem second = CreateItem(Path.Combine(_tempDirectory.FullPath, "case.mp3"), "Two");
|
||||
|
||||
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, "");
|
||||
PlanItem missingSource = CreateItem(Path.Combine(_tempDirectory.FullPath, "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));
|
||||
}
|
||||
|
||||
/// <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) {
|
||||
string path = Path.Combine(_tempDirectory.FullPath, fileName);
|
||||
string directory = Path.GetDirectoryName(path) ?? _tempDirectory.FullPath;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user