feat: implement plan XML persistence
This commit is contained in:
1 parent
14b938ea67
commit
c815a7d49f
4 files changed
+482
No files matched your search
@@ -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;
|
||||||
|
|
||||||
|
/// <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() {
|
||||||
|
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("<?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]);
|
||||||
|
} finally {
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#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,32 @@
|
|||||||
|
using System.Xml.Serialization;
|
||||||
|
|
||||||
|
namespace SongTidy.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one file entry stored in a plan document.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PlanItem {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the absolute source file path.
|
||||||
|
/// </summary>
|
||||||
|
[XmlAttribute("Path")]
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the artist value.
|
||||||
|
/// </summary>
|
||||||
|
[XmlAttribute("Artist")]
|
||||||
|
public string Artist { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the title value.
|
||||||
|
/// </summary>
|
||||||
|
[XmlAttribute("Title")]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the comment value.
|
||||||
|
/// </summary>
|
||||||
|
[XmlAttribute("Comment")]
|
||||||
|
public string Comment { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
|
||||||
|
namespace SongTidy.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a saved plan document containing the selected format and all entries.
|
||||||
|
/// </summary>
|
||||||
|
[XmlRoot("Plan")]
|
||||||
|
public sealed class SongPlan {
|
||||||
|
/// <summary>
|
||||||
|
/// The plan file version written for new plans.
|
||||||
|
/// </summary>
|
||||||
|
public const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the plan file version.
|
||||||
|
/// </summary>
|
||||||
|
[XmlAttribute("version")]
|
||||||
|
public int Version { get; set; } = CurrentVersion;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the naming format used by the plan.
|
||||||
|
/// </summary>
|
||||||
|
[XmlElement("Format")]
|
||||||
|
public RenameFormat Format { get; set; } = RenameFormat.ArtistFirst;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the entries stored in the plan.
|
||||||
|
/// </summary>
|
||||||
|
[XmlArray("Items")]
|
||||||
|
[XmlArrayItem("Item")]
|
||||||
|
public List<PlanItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Serialization;
|
||||||
|
|
||||||
|
using SongTidy.Models;
|
||||||
|
|
||||||
|
namespace SongTidy.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads and writes plan documents in the application's XML format.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlanXmlIO {
|
||||||
|
#region Stream access
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a plan to a stream as UTF-8 XML.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="plan">The plan to write.</param>
|
||||||
|
/// <param name="stream">The destination stream. The stream is left open.</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a plan from a stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stream">The source stream. The stream is left open.</param>
|
||||||
|
/// <returns>The deserialized plan.</returns>
|
||||||
|
/// <exception cref="InvalidDataException">The document does not contain a plan.</exception>
|
||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a plan to a UTF-8 XML file, overwriting an existing file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="plan">The plan to write.</param>
|
||||||
|
/// <param name="filePath">The destination file path.</param>
|
||||||
|
public static void Save(SongPlan plan, string filePath) {
|
||||||
|
using FileStream stream = new(filePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||||
|
Save(plan, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a plan from an XML file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="filePath">The source file path.</param>
|
||||||
|
/// <returns>The deserialized plan.</returns>
|
||||||
|
public static SongPlan Load(string filePath) {
|
||||||
|
using FileStream stream = new(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
return Load(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Helpers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the XML writer settings used for plan files.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Writer settings that produce readable UTF-8 XML without a byte order mark.</returns>
|
||||||
|
private static XmlWriterSettings CreateWriterSettings() {
|
||||||
|
return new XmlWriterSettings {
|
||||||
|
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
|
||||||
|
Indent = true,
|
||||||
|
IndentChars = " ",
|
||||||
|
CloseOutput = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the XML reader settings used for plan files.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Reader settings that prohibit DTD processing and leave the input stream open.</returns>
|
||||||
|
private static XmlReaderSettings CreateReaderSettings() {
|
||||||
|
return new XmlReaderSettings {
|
||||||
|
DtdProcessing = DtdProcessing.Prohibit,
|
||||||
|
XmlResolver = null,
|
||||||
|
CloseInput = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in new issue
Block a user