Files
SongTidy/SongTidy.Tests/Services/PlanXmlIOTests.cs
T

307 lines
10 KiB
C#
Raw Normal View History

2026-10-22 20:48:18 +08:00
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml.Linq;
using SongTidy.Models;
using SongTidy.Services;
2026-10-22 21:24:13 +08:00
using SongTidy.Tests.TestUtilities;
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
namespace SongTidy.Tests.Services;
2026-10-22 20:48:18 +08:00
/// <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("&amp;", StringComparison.Ordinal));
Assert.IsTrue(xml.Contains("&quot;", StringComparison.Ordinal));
Assert.IsTrue(xml.Contains("&lt;", StringComparison.Ordinal));
Assert.IsTrue(xml.Contains("&gt;", 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() {
2026-10-22 21:24:13 +08:00
using TempDirectory tempDirectory = new();
string filePath = Path.Combine(tempDirectory.FullPath, "plan with spaces.xml");
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
SongPlan plan = CreateSamplePlan();
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
PlanXmlIO.Save(plan, filePath);
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
Assert.IsTrue(File.Exists(filePath));
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
byte[] bytes = File.ReadAllBytes(filePath);
Assert.AreEqual((byte)'<', bytes[0]);
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
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.");
2026-10-22 20:48:18 +08:00
2026-10-22 21:24:13 +08:00
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]);
2026-10-22 20:48:18 +08:00
}
#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);
}
}