diff --git a/SongTidy.Tests/RenameFormatsTests.cs b/SongTidy.Tests/RenameFormatsTests.cs new file mode 100644 index 0000000..929d496 --- /dev/null +++ b/SongTidy.Tests/RenameFormatsTests.cs @@ -0,0 +1,146 @@ +using SongTidy.Models; + +namespace SongTidy.Tests; + +/// +/// Contains tests for . +/// +[TestClass] +public sealed class RenameFormatsTests { + #region Artist-first format + + /// + /// Verifies the artist-first layout when both optional fields are present. + /// + [TestMethod] + public void GeneratesArtistFirstWithArtistAndComment() { + AssertStem(RenameFormat.ArtistFirst, "Artist", "Title", "Live", "Artist - Title (Live)"); + } + + /// + /// Verifies the artist-first layout when only the artist is present. + /// + [TestMethod] + public void GeneratesArtistFirstWithArtistOnly() { + AssertStem(RenameFormat.ArtistFirst, "Artist", "Title", string.Empty, "Artist - Title"); + } + + /// + /// Verifies the artist-first layout when only the comment is present. + /// + [TestMethod] + public void GeneratesArtistFirstWithCommentOnly() { + AssertStem(RenameFormat.ArtistFirst, string.Empty, "Title", "Live", "Title (Live)"); + } + + /// + /// Verifies the artist-first layout when neither optional field is present. + /// + [TestMethod] + public void GeneratesArtistFirstWithTitleOnly() { + AssertStem(RenameFormat.ArtistFirst, string.Empty, "Title", string.Empty, "Title"); + } + + #endregion + + #region Title-first format + + /// + /// Verifies the title-first layout when both optional fields are present. + /// + [TestMethod] + public void GeneratesTitleFirstWithArtistAndComment() { + AssertStem(RenameFormat.TitleFirst, "Artist", "Title", "Live", "Title (Live) - Artist"); + } + + /// + /// Verifies the title-first layout when only the artist is present. + /// + [TestMethod] + public void GeneratesTitleFirstWithArtistOnly() { + AssertStem(RenameFormat.TitleFirst, "Artist", "Title", string.Empty, "Title - Artist"); + } + + /// + /// Verifies the title-first layout when only the comment is present. + /// + [TestMethod] + public void GeneratesTitleFirstWithCommentOnly() { + AssertStem(RenameFormat.TitleFirst, string.Empty, "Title", "Live", "Title (Live)"); + } + + /// + /// Verifies the title-first layout when neither optional field is present. + /// + [TestMethod] + public void GeneratesTitleFirstWithTitleOnly() { + AssertStem(RenameFormat.TitleFirst, string.Empty, "Title", string.Empty, "Title"); + } + + #endregion + + #region Registry + + /// + /// Verifies that every enum member has a registered format definition. + /// + [TestMethod] + public void RegistersEveryFormat() { + foreach (RenameFormat format in Enum.GetValues()) { + Assert.IsTrue( + RenameFormats.Definitions.ContainsKey(format), + $"Missing definition for {format}."); + } + } + + /// + /// Verifies that an unregistered enum value is rejected. + /// + [TestMethod] + public void ThrowsForUnregisteredFormat() { + Assert.Throws( + () => RenameFormats.GenerateStem((RenameFormat)999, "Artist", "Title", "Live")); + } + + #endregion + + #region Edge cases + + /// + /// Verifies that whitespace-only optional fields are treated as missing. + /// + [TestMethod] + public void TreatsWhitespaceOnlyOptionalFieldsAsMissing() { + AssertStem(RenameFormat.ArtistFirst, " ", "Title", "\t", "Title"); + } + + /// + /// Verifies that a null title is rejected. + /// + [TestMethod] + public void ThrowsForNullTitle() { + Assert.Throws( + () => RenameFormats.GenerateStem(RenameFormat.ArtistFirst, "Artist", null!, "Live")); + } + + #endregion + + /// + /// Generates a file name stem and asserts the result. + /// + /// The format to apply. + /// The artist value. + /// The title value. + /// The comment value. + /// The expected generated stem. + private static void AssertStem( + RenameFormat format, + string artist, + string title, + string comment, + string expected) { + string actual = RenameFormats.GenerateStem(format, artist, title, comment); + + Assert.AreEqual(expected, actual, $"Unexpected stem for {format}."); + } +} diff --git a/SongTidy/Models/RenameFormats.cs b/SongTidy/Models/RenameFormats.cs new file mode 100644 index 0000000..a53620a --- /dev/null +++ b/SongTidy/Models/RenameFormats.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Xml.Serialization; + +namespace SongTidy.Models; + +/// +/// Identifies a supported layout for generated target file names. +/// +public enum RenameFormat { + /// + /// Places the artist before the title. + /// + [XmlEnum("ArtistFirst")] + ArtistFirst, + + /// + /// Places the title before the artist. + /// + [XmlEnum("TitleFirst")] + TitleFirst, +} + +/// +/// Represents the artist, title, and comment values used to build a target file name. +/// +/// The artist value, which may be empty. +/// The title value. +/// The comment value, which may be empty. +public sealed record RenameFields(string Artist, string Title, string Comment); + +/// +/// Formats name fields into a target file name stem. +/// +/// The fields to format. +/// The formatted file name stem. +public delegate string RenameFormatFormatter(RenameFields fields); + +/// +/// Defines the formatter functions used to generate a target file name for one format. +/// +/// The formatter used when both optional fields are present. +/// The formatter used when only the artist is present. +/// The formatter used when only the comment is present. +/// The formatter used when neither optional field is present. +public sealed record RenameFormatDefinition( + RenameFormatFormatter ArtistAndComment, + RenameFormatFormatter ArtistOnly, + RenameFormatFormatter CommentOnly, + RenameFormatFormatter Plain); + +/// +/// Provides the registered target file name formats and generates file name stems from them. +/// +public static class RenameFormats { + #region Registry + + private static readonly ReadOnlyDictionary s_definitions = + new ReadOnlyDictionary( + new Dictionary { + [RenameFormat.ArtistFirst] = new RenameFormatDefinition( + ArtistAndComment: static fields => $"{fields.Artist} - {fields.Title} ({fields.Comment})", + ArtistOnly: static fields => $"{fields.Artist} - {fields.Title}", + CommentOnly: static fields => $"{fields.Title} ({fields.Comment})", + Plain: static fields => fields.Title), + [RenameFormat.TitleFirst] = new RenameFormatDefinition( + ArtistAndComment: static fields => $"{fields.Title} ({fields.Comment}) - {fields.Artist}", + ArtistOnly: static fields => $"{fields.Title} - {fields.Artist}", + CommentOnly: static fields => $"{fields.Title} ({fields.Comment})", + Plain: static fields => fields.Title), + }); + + /// + /// Gets the registered format definitions keyed by format. + /// + public static IReadOnlyDictionary Definitions => s_definitions; + + /// + /// Looks up the definition registered for a format. + /// + /// The format to look up. + /// The registered definition. + /// The format is not registered. + private static RenameFormatDefinition GetDefinition(RenameFormat format) { + if (!s_definitions.TryGetValue(format, out RenameFormatDefinition? definition)) { + throw new ArgumentOutOfRangeException(nameof(format), format, "The format is not registered."); + } + + return definition; + } + + #endregion + + #region Generation + + /// + /// Generates the target file name stem for the supplied fields and format. + /// + /// The naming format to apply. + /// The artist value, which may be empty. + /// The title value, which is always inserted. + /// The comment value, which may be empty. + /// + /// The generated file name stem. The caller appends the original extension because + /// extensions are not part of the format definition. + /// + /// + /// Whitespace-only optional fields are treated as missing. + /// + public static string GenerateStem(RenameFormat format, string artist, string title, string comment) { + ArgumentNullException.ThrowIfNull(artist); + ArgumentNullException.ThrowIfNull(title); + ArgumentNullException.ThrowIfNull(comment); + + RenameFormatDefinition definition = GetDefinition(format); + bool hasArtist = !string.IsNullOrWhiteSpace(artist); + bool hasComment = !string.IsNullOrWhiteSpace(comment); + RenameFormatFormatter formatter; + + // Select the formatter that matches which optional fields are present. + if (hasArtist && hasComment) { + formatter = definition.ArtistAndComment; + } else if (hasArtist) { + formatter = definition.ArtistOnly; + } else if (hasComment) { + formatter = definition.CommentOnly; + } else { + formatter = definition.Plain; + } + + return formatter(new RenameFields(artist, title, comment)); + } + + #endregion +}