diff --git a/AppScaffolding/AppScaffolding.csproj b/AppScaffolding/AppScaffolding.csproj index 831d139f..b23e4756 100644 --- a/AppScaffolding/AppScaffolding.csproj +++ b/AppScaffolding/AppScaffolding.csproj @@ -3,7 +3,7 @@ net5.0 - 6.2.8.3 + 6.2.9.1 diff --git a/AppScaffolding/LibationScaffolding.cs b/AppScaffolding/LibationScaffolding.cs index 53927b24..e988165b 100644 --- a/AppScaffolding/LibationScaffolding.cs +++ b/AppScaffolding/LibationScaffolding.cs @@ -59,6 +59,7 @@ namespace AppScaffolding Migrations.migrate_to_v5_7_1(config); Migrations.migrate_to_v6_1_2(config); Migrations.migrate_to_v6_2_0(config); + Migrations.migrate_to_v6_2_9(config); } /// Initialize logging. Run after migration @@ -345,5 +346,18 @@ namespace AppScaffolding if (!config.Exists(nameof(config.SplitFilesByChapter))) config.SplitFilesByChapter = false; } + + // add file naming templates + public static void migrate_to_v6_2_9(Configuration config) + { + if (!config.Exists(nameof(config.FolderTemplate))) + config.FolderTemplate = Templates.Folder.DefaultTemplate; + + if (!config.Exists(nameof(config.FileTemplate))) + config.FileTemplate = Templates.File.DefaultTemplate; + + if (!config.Exists(nameof(config.ChapterFileTemplate))) + config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate; + } } } diff --git a/FileLiberator/AudioFileStorageExt.cs b/FileLiberator/AudioFileStorageExt.cs index 7d4f4ffd..d799d547 100644 --- a/FileLiberator/AudioFileStorageExt.cs +++ b/FileLiberator/AudioFileStorageExt.cs @@ -11,9 +11,8 @@ namespace FileLiberator { public static class AudioFileStorageExt { - private static string TEMP_SINGLE_TEMPLATE { get; } = " [<id>]"; - private static string TEMP_DIR_TEMPLATE { get; } = "<title short> [<id>]"; - private static string TEMP_MULTI_TEMPLATE { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; + private static void AddParameterReplacement(this FileTemplate fileTemplate, TemplateTags templateTags, object value) + => fileTemplate.AddParameterReplacement(templateTags.TagName, value); internal class MultipartRenamer { @@ -22,16 +21,16 @@ namespace FileLiberator public MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook; internal string MultipartFilename(string outputFileName, int partsPosition, int partsTotal, AAXClean.NewSplitCallback newSplitCallback) - => MultipartFilename(TEMP_MULTI_TEMPLATE, AudibleFileStorage.DecryptInProgressDirectory, Path.GetExtension(outputFileName), partsPosition, partsTotal, newSplitCallback?.Chapter?.Title ?? ""); + => MultipartFilename(Configuration.Instance.ChapterFileTemplate, AudibleFileStorage.DecryptInProgressDirectory, Path.GetExtension(outputFileName), partsPosition, partsTotal, newSplitCallback?.Chapter?.Title ?? ""); internal string MultipartFilename(string template, string fullDirPath, string extension, int partsPosition, int partsTotal, string chapterTitle) { var fileTemplate = GetFileTemplateSingle(template, libraryBook, fullDirPath, extension); - fileTemplate.AddParameterReplacement("ch count", partsTotal.ToString()); - fileTemplate.AddParameterReplacement("ch#", partsPosition.ToString()); - fileTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(partsPosition, partsTotal)); - fileTemplate.AddParameterReplacement("ch title", chapterTitle); + fileTemplate.AddParameterReplacement(TemplateTags.ChCount, partsTotal); + fileTemplate.AddParameterReplacement(TemplateTags.ChNumber, partsPosition); + fileTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(partsPosition, partsTotal)); + fileTemplate.AddParameterReplacement(TemplateTags.ChTitle, chapterTitle); return fileTemplate.GetFilePath(); } @@ -47,11 +46,11 @@ namespace FileLiberator => GetCustomDirFilename(_, libraryBook, AudibleFileStorage.BooksDirectory, extension); public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook) - => GetFileTemplateSingle(TEMP_DIR_TEMPLATE, libraryBook, AudibleFileStorage.BooksDirectory, null) + => GetFileTemplateSingle(Configuration.Instance.FolderTemplate, libraryBook, AudibleFileStorage.BooksDirectory, null) .GetFilePath(); public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension) - => GetFileTemplateSingle(TEMP_SINGLE_TEMPLATE, libraryBook, dirFullPath, extension) + => GetFileTemplateSingle(Configuration.Instance.FolderTemplate, libraryBook, dirFullPath, extension) .GetFilePath(); internal static FileTemplate GetFileTemplateSingle(string template, LibraryBook libraryBook, string dirFullPath, string extension) @@ -65,9 +64,17 @@ namespace FileLiberator var title = libraryBook.Book.Title ?? ""; - fileTemplate.AddParameterReplacement("title", title); - fileTemplate.AddParameterReplacement("title short", title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'))); - fileTemplate.AddParameterReplacement("id", libraryBook.Book.AudibleProductId); + fileTemplate.AddParameterReplacement(TemplateTags.Id, libraryBook.Book.AudibleProductId); + fileTemplate.AddParameterReplacement(TemplateTags.Title, title); + fileTemplate.AddParameterReplacement(TemplateTags.TitleShort, title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'))); + fileTemplate.AddParameterReplacement(TemplateTags.Author, libraryBook.Book.AuthorNames); + fileTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBook.Book.Authors.FirstOrDefault()?.Name); + fileTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBook.Book.NarratorNames); + fileTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBook.Book.Narrators.FirstOrDefault()?.Name); + + var seriesLink = libraryBook.Book.SeriesLink.FirstOrDefault(); + fileTemplate.AddParameterReplacement(TemplateTags.SeriesName, seriesLink?.Series.Name); + fileTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, seriesLink?.Order); return fileTemplate; } diff --git a/FileManager/FileTemplate.cs b/FileManager/FileTemplate.cs index 138bbfc5..8fab2de0 100644 --- a/FileManager/FileTemplate.cs +++ b/FileManager/FileTemplate.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Dinah.Core; @@ -16,10 +15,12 @@ namespace FileManager public FileTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template)); /// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary> - public Dictionary<string, string> ParameterReplacements { get; } = new Dictionary<string, string>(); + public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>(); /// <summary>Convenience method</summary> - public void AddParameterReplacement(string key ,string value) => ParameterReplacements.Add(key, value); + public void AddParameterReplacement(string key, object value) + // using .Add() instead of "[key] = value" will make unintended overwriting throw exception + => ParameterReplacements.Add(key, value); /// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary> public int? ParameterMaxSize { get; set; } = 50; @@ -38,14 +39,14 @@ namespace FileManager return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements); } - private string formatKey(string key) + private static string formatKey(string key) => key .Replace("<", "") .Replace(">", ""); - private string formatValue(string value) + private string formatValue(object value) => ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0 - ? value?.Truncate(ParameterMaxSize.Value) - : value; + ? value?.ToString().Truncate(ParameterMaxSize.Value) + : value?.ToString(); } } diff --git a/Libation.sln b/Libation.sln index 6729b1cd..fe88ed24 100644 --- a/Libation.sln +++ b/Libation.sln @@ -62,6 +62,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator.Tests", "_Tes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests\FileManager.Tests\FileManager.Tests.csproj", "{F2E04270-4551-41C4-99FF-E7125BED708C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -136,6 +138,10 @@ Global {F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.Build.0 = Release|Any CPU + {EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -158,6 +164,7 @@ Global {788294BE-0D8E-40D4-9CEE-67896FBB52CE} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} {5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} {F2E04270-4551-41C4-99FF-E7125BED708C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} + {EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9} diff --git a/LibationFileManager/Configuration.cs b/LibationFileManager/Configuration.cs index 3e701084..2105ed81 100644 --- a/LibationFileManager/Configuration.cs +++ b/LibationFileManager/Configuration.cs @@ -145,8 +145,40 @@ namespace LibationFileManager { get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes)); set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value); + } + + [Description("How to format the folders in which files will be saved")] + public string FolderTemplate + { + get => getTemplate(nameof(FolderTemplate), Templates.Folder); + set => setTemplate(nameof(FolderTemplate), Templates.Folder, value); } + [Description("How to format the saved pdf and audio files")] + public string FileTemplate + { + get => getTemplate(nameof(FileTemplate), Templates.File); + set => setTemplate(nameof(FileTemplate), Templates.File, value); + } + + [Description("How to format the saved audio files which are split by chapters")] + public string ChapterFileTemplate + { + get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile); + set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value); + } + + private string getTemplate(string settingName, Templates templ) + { + var value = persistentDictionary.GetString(settingName).Trim(); + return templ.IsValid(value) ? value : templ.DefaultTemplate; + } + private void setTemplate(string settingName, Templates templ, string newValue) + { + var template = newValue.Trim(); + if (templ.IsValid(template)) + persistentDictionary.SetString(settingName, template); + } #endregion #region known directories diff --git a/LibationFileManager/TemplateTags.cs b/LibationFileManager/TemplateTags.cs new file mode 100644 index 00000000..262cb853 --- /dev/null +++ b/LibationFileManager/TemplateTags.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Dinah.Core; + +namespace LibationFileManager +{ + public sealed class TemplateTags : Enumeration<TemplateTags> + { + public string TagName => DisplayName; + public string Description { get; } + public bool IsChapterOnly { get; } + + private static int value = 0; + private TemplateTags(string tagName, string description, bool isChapterOnly = false) : base(value++, tagName) + { + Description = description; + IsChapterOnly = isChapterOnly; + } + + public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID"); + public static TemplateTags Title { get; } = new TemplateTags("title", "Full title"); + public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon"); + public static TemplateTags Author { get; } = new TemplateTags("author", "Author(s)"); + public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author"); + public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)"); + public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator"); + public static TemplateTags SeriesName { get; } = new TemplateTags("series name", "Name of series"); + // can't also have a leading zeros version. Too many weird edge cases. Eg: "1-4" + public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series"); + + public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true); + public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true); + public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter number", true); + public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter number with leading zeros", true); + } +} diff --git a/LibationFileManager/Templates.cs b/LibationFileManager/Templates.cs new file mode 100644 index 00000000..c51b27dc --- /dev/null +++ b/LibationFileManager/Templates.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationFileManager +{ + public abstract class Templates + { + public static Templates Folder { get; } = new FolderTemplate(); + public static Templates File { get; } = new FileTemplate(); + public static Templates ChapterFile { get; } = new ChapterFileTemplate(); + + public abstract string DefaultTemplate { get; } + + public abstract bool IsValid(string template); + public abstract bool IsRecommended(string template); + public abstract int TagCount(string template); + + public static bool ContainsChapterOnlyTags(string template) + => TemplateTags.GetAll() + .Where(t => t.IsChapterOnly) + .Any(t => ContainsTag(template, t.TagName)); + + public static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>"); + + protected static bool fileIsValid(string template) + // File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save. + // null is invalid. whitespace is valid but not recommended + => template is not null + && !template.Contains(':') + && !template.Contains(System.IO.Path.DirectorySeparatorChar) + && !template.Contains(System.IO.Path.AltDirectorySeparatorChar); + + protected bool isRecommended(string template, bool isChapter) + => IsValid(template) + && !string.IsNullOrWhiteSpace(template) + && TagCount(template) > 0 + && ContainsChapterOnlyTags(template) == isChapter; + + protected static int tagCount(string template, Func<TemplateTags, bool> func) + => TemplateTags.GetAll() + .Where(func) + // for <id><id> == 1, use: + // .Count(t => template.Contains($"<{t.TagName}>")) + // .Sum() impl: <id><id> == 2 + .Sum(t => template.Split($"<{t.TagName}>").Length - 1); + + private class FolderTemplate : Templates + { + public override string DefaultTemplate { get; } = "<title short> [<id>]"; + + public override bool IsValid(string template) + // must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save. + // null is invalid. whitespace is valid but not recommended + => template is not null + && !template.Contains(':'); + + public override bool IsRecommended(string template) => isRecommended(template, false); + + public override int TagCount(string template) => tagCount(template, t => !t.IsChapterOnly); + } + + private class FileTemplate : Templates + { + public override string DefaultTemplate { get; } = "<title> [<id>]"; + + public override bool IsValid(string template) => fileIsValid(template); + + public override bool IsRecommended(string template) => isRecommended(template, false); + + public override int TagCount(string template) => tagCount(template, t => !t.IsChapterOnly); + } + + private class ChapterFileTemplate : Templates + { + public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; + + public override bool IsValid(string template) => fileIsValid(template); + + public override bool IsRecommended(string template) + => isRecommended(template, true) + // recommended to incl. <ch#> or <ch# 0> + && (ContainsTag(template, TemplateTags.ChNumber.TagName) || ContainsTag(template, TemplateTags.ChNumber0.TagName)); + + public override int TagCount(string template) => tagCount(template, t => true); + } + } +} diff --git a/LibationFileManager/_InternalsVisible.cs b/LibationFileManager/_InternalsVisible.cs new file mode 100644 index 00000000..d0bcf4c8 --- /dev/null +++ b/LibationFileManager/_InternalsVisible.cs @@ -0,0 +1 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(LibationFileManager) + ".Tests")] \ No newline at end of file diff --git a/_Tests/FileManager.Tests/FileUtilityTests.cs b/_Tests/FileManager.Tests/FileUtilityTests.cs index d9371c15..49ef3a2f 100644 --- a/_Tests/FileManager.Tests/FileUtilityTests.cs +++ b/_Tests/FileManager.Tests/FileUtilityTests.cs @@ -35,6 +35,7 @@ namespace FileUtilityTests [DataRow(@"a\b:c\d.txt", "ZZZ", @"a\bZZZc\d.txt")] // remove empty directories [DataRow(@"C:\a\\\b\c\\\d.txt", "ZZZ", @"C:\a\b\c\d.txt")] + [DataRow(@"C:\""foo\<id>", "ZZZ", @"C:\ZZZfoo\ZZZidZZZ")] public void Tests(string inStr, string replacement, string outStr) => Assert.AreEqual(outStr, FileUtility.GetSafePath(inStr, replacement)); } diff --git a/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj b/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj new file mode 100644 index 00000000..0aaf853d --- /dev/null +++ b/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="FluentAssertions" Version="6.2.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" /> + <PackageReference Include="MSTest.TestAdapter" Version="2.2.7" /> + <PackageReference Include="MSTest.TestFramework" Version="2.2.7" /> + <PackageReference Include="coverlet.collector" Version="3.1.0"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\LibationFileManager\LibationFileManager.csproj" /> + </ItemGroup> + +</Project> diff --git a/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/_Tests/LibationFileManager.Tests/TemplatesTests.cs new file mode 100644 index 00000000..9010744a --- /dev/null +++ b/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dinah.Core; +using FluentAssertions; +using LibationFileManager; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace TemplatesTests +{ + [TestClass] + public class ContainsChapterOnlyTags + { + [TestMethod] + [DataRow("<ch>", false)] + [DataRow("<ch#>", true)] + [DataRow("<id>", false)] + [DataRow("<id><ch#>", true)] + public void Tests(string template, bool expected) => Templates.ContainsChapterOnlyTags(template).Should().Be(expected); + } + + [TestClass] + public class ContainsTag + { + [TestMethod] + [DataRow("<ch#>", "ch#", true)] + [DataRow("<id>", "ch#", false)] + [DataRow("<id><ch#>", "ch#", true)] + public void Tests(string template, string tag, bool expected) => Templates.ContainsTag(template, tag).Should().Be(expected); + } +} + +namespace Templates_Folder_Tests +{ + [TestClass] + public class IsValid + { + [TestMethod] + public void null_is_invalid() => Tests(null, false); + + [TestMethod] + public void empty_is_valid() => Tests("", true); + + [TestMethod] + public void whitespace_is_valid() => Tests(" ", true); + + [TestMethod] + [DataRow(@"C:\", false)] + [DataRow(@"foo", true)] + [DataRow(@"\foo", true)] + [DataRow(@"foo\", true)] + [DataRow(@"\foo\", true)] + [DataRow(@"foo\bar", true)] + [DataRow(@"<id>", true)] + [DataRow(@"<id>\<title>", true)] + public void Tests(string template, bool expected) => Templates.Folder.IsValid(template).Should().Be(expected); + } + + [TestClass] + public class IsRecommended + { + [TestMethod] + public void null_is_not_recommended() => Tests(null, false); + + [TestMethod] + public void empty_is_not_recommended() => Tests("", false); + + [TestMethod] + public void whitespace_is_not_recommended() => Tests(" ", false); + + [TestMethod] + [DataRow(@"no tags", false)] + [DataRow(@"<id>\foo\bar", true)] + [DataRow("<ch#> <id>", false)] + [DataRow("<ch#> chapter tag", false)] + public void Tests(string template, bool expected) => Templates.Folder.IsRecommended(template).Should().Be(expected); + } + + [TestClass] + public class TagCount + { + [TestMethod] + public void null_is_not_recommended() => Assert.ThrowsException<NullReferenceException>(() => Tests(null, -1)); + + [TestMethod] + public void empty_is_not_recommended() => Tests("", 0); + + [TestMethod] + public void whitespace_is_not_recommended() => Tests(" ", 0); + + [TestMethod] + [DataRow("no tags", 0)] + [DataRow(@"<id>\foo\bar", 1)] + [DataRow("<id> <id>", 2)] + [DataRow("<id <id> >", 1)] + [DataRow("<id> <title>", 2)] + [DataRow("id> <title incomplete tags", 0)] + [DataRow("<not a real tag>", 0)] + [DataRow("<ch#> non-folder tag", 0)] + [DataRow("<ID> case specific", 0)] + public void Tests(string template, int expected) => Templates.Folder.TagCount(template).Should().Be(expected); + } +} + +namespace Templates_File_Tests +{ + [TestClass] + public class IsValid + { + [TestMethod] + public void null_is_invalid() => Tests(null, false); + + [TestMethod] + public void empty_is_valid() => Tests("", true); + + [TestMethod] + public void whitespace_is_valid() => Tests(" ", true); + + [TestMethod] + [DataRow(@"C:\", false)] + [DataRow(@"foo", true)] + [DataRow(@"\foo", false)] + [DataRow(@"/foo", false)] + [DataRow(@"<id>", true)] + public void Tests(string template, bool expected) => Templates.File.IsValid(template).Should().Be(expected); + } + + // same as Templates.Folder.IsRecommended + //[TestClass] + //public class IsRecommended { } + + // same as Templates.Folder.TagCount + //[TestClass] + //public class TagCount { } +} + +namespace Templates_ChapterFile_Tests +{ + // same as Templates.File.IsValid + //[TestClass] + //public class IsValid { } + + [TestClass] + public class IsRecommended + { + [TestMethod] + public void null_is_not_recommended() => Tests(null, false); + + [TestMethod] + public void empty_is_not_recommended() => Tests("", false); + + [TestMethod] + public void whitespace_is_not_recommended() => Tests(" ", false); + + [TestMethod] + [DataRow(@"no tags", false)] + [DataRow(@"<id>\foo\bar", false)] + [DataRow("<ch#> <id>", true)] + [DataRow("<ch#> -- chapter tag", true)] + [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", false)] + public void Tests(string template, bool expected) => Templates.ChapterFile.IsRecommended(template).Should().Be(expected); + } + + [TestClass] + public class TagCount + { + [TestMethod] + public void null_is_not_recommended() => Assert.ThrowsException<NullReferenceException>(() => Tests(null, -1)); + + [TestMethod] + public void empty_is_not_recommended() => Tests("", 0); + + [TestMethod] + public void whitespace_is_not_recommended() => Tests(" ", 0); + + [TestMethod] + [DataRow("no tags", 0)] + [DataRow(@"<id>\foo\bar", 1)] + [DataRow("<id> <id>", 2)] + [DataRow("<id <id> >", 1)] + [DataRow("<id> <title>", 2)] + [DataRow("id> <title incomplete tags", 0)] + [DataRow("<not a real tag>", 0)] + [DataRow("<ch#> non-folder tag", 1)] + [DataRow("<ID> case specific", 0)] + public void Tests(string template, int expected) => Templates.ChapterFile.TagCount(template).Should().Be(expected); + } +}