using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Xml.Linq; using SongTidy.Models; using SongTidy.Services; namespace SongTidy.Tests; /// /// Contains tests for . /// [TestClass] public sealed class PlanXmlIOTests { #region Document structure /// /// Verifies the generated XML root, version, format, and item attributes. /// [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("", StringComparison.Ordinal), "The XML declaration should specify UTF-8."); } /// /// Verifies that empty fields are still written as empty attributes. /// [TestMethod] public void WritesEmptyFieldsAsAttributes() { SongPlan plan = new() { Items = new List { 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 /// /// Verifies that both format enum values survive a round trip. /// /// The format to round-trip. [TestMethod] [DataRow(RenameFormat.ArtistFirst)] [DataRow(RenameFormat.TitleFirst)] public void RoundTripsFormat(RenameFormat format) { SongPlan plan = new() { Format = format, Items = new List { new PlanItem { Path = @"C:\Music\C.mp3", Title = "C" }, }, }; SongPlan loaded = RoundTrip(plan); Assert.AreEqual(format, loaded.Format); } /// /// Verifies that every field and the version survive a round trip. /// [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]); } /// /// Verifies that a non-default version attribute is preserved. /// [TestMethod] public void PreservesVersion() { SongPlan plan = CreateSamplePlan(); int version = SongPlan.CurrentVersion + 1; plan.Version = version; SongPlan loaded = RoundTrip(plan); Assert.AreEqual(version, loaded.Version); } /// /// Verifies that paths and fields containing spaces and XML special characters round-trip. /// [TestMethod] public void RoundTripsPathsWithSpacesAndSpecialCharacters() { const string path = @"D:\Music\A & B ""quoted"" 'single' - (Live).mp3"; SongPlan plan = new() { Items = new List { new PlanItem { Path = path, Artist = "A & B", Title = "Title ", 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 ", loaded.Items[0].Title); Assert.AreEqual("\"Comment\"", loaded.Items[0].Comment); } #endregion #region Loading /// /// Verifies that missing optional attributes are loaded as empty strings. /// [TestMethod] public void LoadsMissingOptionalAttributesAsEmpty() { string xml = $""" TitleFirst """; 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); } /// /// Verifies that a missing Items element leaves an empty initialized list. /// [TestMethod] public void LoadsMissingItemsAsEmptyList() { string xml = $""" ArtistFirst """; SongPlan plan = LoadFromString(xml); Assert.HasCount(0, plan.Items); } #endregion #region File access /// /// Verifies file save and load with spaces in the file path and UTF-8 output without a BOM. /// [TestMethod] public void SavesAndLoadsFileWithSpacesInPath() { string directory = Path.Combine(Path.GetTempPath(), $"SongTidy Plan Test {Guid.NewGuid():N}"); string filePath = Path.Combine(directory, "plan with spaces.xml"); Directory.CreateDirectory(directory); try { 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("", 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]); } finally { Directory.Delete(directory, recursive: true); } } #endregion /// /// Creates a sample plan containing two entries. /// /// The sample plan. private static SongPlan CreateSamplePlan() { return new SongPlan { Format = RenameFormat.ArtistFirst, Items = new List { 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, }, }, }; } /// /// Saves a plan to an in-memory stream and returns the decoded XML. /// /// The plan to save. /// The generated XML text. private static string SaveToString(SongPlan plan) { using MemoryStream stream = new(); PlanXmlIO.Save(plan, stream); return Encoding.UTF8.GetString(stream.ToArray()); } /// /// Loads a plan from XML text. /// /// The XML text to load. /// The loaded plan. private static SongPlan LoadFromString(string xml) { using MemoryStream stream = new(Encoding.UTF8.GetBytes(xml)); return PlanXmlIO.Load(stream); } /// /// Saves and reloads a plan through an in-memory stream. /// /// The plan to round-trip. /// The reloaded plan. private static SongPlan RoundTrip(SongPlan plan) { using MemoryStream stream = new(); PlanXmlIO.Save(plan, stream); stream.Position = 0; return PlanXmlIO.Load(stream); } /// /// Asserts that two plan items contain the same field values. /// /// The expected item. /// The actual item. 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); } }