From c815a7d49f11c29c342f71bd5431be00c8adbfc9 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 22 Oct 2026 20:48:18 +0800 Subject: [PATCH] feat: implement plan XML persistence --- SongTidy.Tests/PlanXmlIOTests.cs | 311 +++++++++++++++++++++++++++++++ SongTidy/Models/PlanItem.cs | 32 ++++ SongTidy/Models/SongPlan.cs | 34 ++++ SongTidy/Services/PlanXmlIO.cs | 105 +++++++++++ 4 files changed, 482 insertions(+) create mode 100644 SongTidy.Tests/PlanXmlIOTests.cs create mode 100644 SongTidy/Models/PlanItem.cs create mode 100644 SongTidy/Models/SongPlan.cs create mode 100644 SongTidy/Services/PlanXmlIO.cs diff --git a/SongTidy.Tests/PlanXmlIOTests.cs b/SongTidy.Tests/PlanXmlIOTests.cs new file mode 100644 index 0000000..68240fb --- /dev/null +++ b/SongTidy.Tests/PlanXmlIOTests.cs @@ -0,0 +1,311 @@ +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); + } +} diff --git a/SongTidy/Models/PlanItem.cs b/SongTidy/Models/PlanItem.cs new file mode 100644 index 0000000..545a2fc --- /dev/null +++ b/SongTidy/Models/PlanItem.cs @@ -0,0 +1,32 @@ +using System.Xml.Serialization; + +namespace SongTidy.Models; + +/// +/// Represents one file entry stored in a plan document. +/// +public sealed class PlanItem { + /// + /// Gets or sets the absolute source file path. + /// + [XmlAttribute("Path")] + public string Path { get; set; } = string.Empty; + + /// + /// Gets or sets the artist value. + /// + [XmlAttribute("Artist")] + public string Artist { get; set; } = string.Empty; + + /// + /// Gets or sets the title value. + /// + [XmlAttribute("Title")] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the comment value. + /// + [XmlAttribute("Comment")] + public string Comment { get; set; } = string.Empty; +} diff --git a/SongTidy/Models/SongPlan.cs b/SongTidy/Models/SongPlan.cs new file mode 100644 index 0000000..df4c275 --- /dev/null +++ b/SongTidy/Models/SongPlan.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Xml.Serialization; + +namespace SongTidy.Models; + +/// +/// Represents a saved plan document containing the selected format and all entries. +/// +[XmlRoot("Plan")] +public sealed class SongPlan { + /// + /// The plan file version written for new plans. + /// + public const int CurrentVersion = 1; + + /// + /// Gets or sets the plan file version. + /// + [XmlAttribute("version")] + public int Version { get; set; } = CurrentVersion; + + /// + /// Gets or sets the naming format used by the plan. + /// + [XmlElement("Format")] + public RenameFormat Format { get; set; } = RenameFormat.ArtistFirst; + + /// + /// Gets or sets the entries stored in the plan. + /// + [XmlArray("Items")] + [XmlArrayItem("Item")] + public List Items { get; set; } = new(); +} diff --git a/SongTidy/Services/PlanXmlIO.cs b/SongTidy/Services/PlanXmlIO.cs new file mode 100644 index 0000000..9be50de --- /dev/null +++ b/SongTidy/Services/PlanXmlIO.cs @@ -0,0 +1,105 @@ +using System.IO; +using System.Text; +using System.Xml; +using System.Xml.Serialization; + +using SongTidy.Models; + +namespace SongTidy.Services; + +/// +/// Reads and writes plan documents in the application's XML format. +/// +public static class PlanXmlIO { + #region Stream access + + /// + /// Writes a plan to a stream as UTF-8 XML. + /// + /// The plan to write. + /// The destination stream. The stream is left open. + public static void Save(SongPlan plan, Stream stream) { + XmlSerializer serializer = new(typeof(SongPlan)); + + // Suppress xsi and xsd namespace attributes to keep the plan file compact. + XmlSerializerNamespaces namespaces = new(); + namespaces.Add(string.Empty, string.Empty); + + using XmlWriter writer = XmlWriter.Create(stream, CreateWriterSettings()); + serializer.Serialize(writer, plan, namespaces); + } + + /// + /// Reads a plan from a stream. + /// + /// The source stream. The stream is left open. + /// The deserialized plan. + /// The document does not contain a plan. + public static SongPlan Load(Stream stream) { + XmlSerializer serializer = new(typeof(SongPlan)); + + using XmlReader reader = XmlReader.Create(stream, CreateReaderSettings()); + SongPlan? plan = (SongPlan?)serializer.Deserialize(reader); + + if (plan is null) { + throw new InvalidDataException("The XML document does not contain a plan."); + } + + return plan; + } + + #endregion + + #region File access + + /// + /// Writes a plan to a UTF-8 XML file, overwriting an existing file. + /// + /// The plan to write. + /// The destination file path. + public static void Save(SongPlan plan, string filePath) { + using FileStream stream = new(filePath, FileMode.Create, FileAccess.Write, FileShare.None); + Save(plan, stream); + } + + /// + /// Reads a plan from an XML file. + /// + /// The source file path. + /// The deserialized plan. + public static SongPlan Load(string filePath) { + using FileStream stream = new(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return Load(stream); + } + + #endregion + + #region Helpers + + /// + /// Creates the XML writer settings used for plan files. + /// + /// Writer settings that produce readable UTF-8 XML without a byte order mark. + private static XmlWriterSettings CreateWriterSettings() { + return new XmlWriterSettings { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = true, + IndentChars = " ", + CloseOutput = false, + }; + } + + /// + /// Creates the XML reader settings used for plan files. + /// + /// Reader settings that prohibit DTD processing and leave the input stream open. + private static XmlReaderSettings CreateReaderSettings() { + return new XmlReaderSettings { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + CloseInput = false, + }; + } + + #endregion +}