From dbd5af8ab0ef7d3a53520c81be478bb8cdbeeb38 Mon Sep 17 00:00:00 2001 From: Jo-Be-Co Date: Sun, 22 Mar 2026 22:22:02 +0100 Subject: [PATCH] QS second part: Made Evaluate() more resilient. Added tests to CommonFormatters. --- .../NamingTemplate/NamingTemplate.cs | 55 +-- .../CommonFormattersTests.cs | 312 ++++++++++++++++++ 2 files changed, 347 insertions(+), 20 deletions(-) create mode 100644 Source/_Tests/FileManager.Tests/CommonFormattersTests.cs diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs index 4db356d2..59a61e9f 100644 --- a/Source/FileManager/NamingTemplate/NamingTemplate.cs +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -17,7 +17,7 @@ public class NamingTemplate private Delegate? _templateToString; private readonly List _warnings = []; private readonly List _errors = []; - private readonly IEnumerable _tagCollections; + private readonly List _tagCollections; private readonly List _tagsInUse = []; public const string ErrorNullIsInvalid = "Null template is invalid."; @@ -36,35 +36,48 @@ public class NamingTemplate // Match propertyClasses to the arguments required by templateToString.DynamicInvoke(). // First parameter is "this", so ignore it. - var parameters = _templateToString.Method.GetParameters(); - var delegateArgTypes = parameters.Skip(1).ToList(); + var delegateArgTypes = _templateToString.Method.GetParameters().Skip(1).Select(p => p.ParameterType).ToList(); + var delegateArgs = new object?[delegateArgTypes.Count]; - var args = new object?[delegateArgTypes.Count]; + var availableObjects = propertyClasses.Where(pc => pc is not null).Cast().ToList(); for (var i = 0; i < delegateArgTypes.Count; i++) { var p = delegateArgTypes[i]; - args[i] = propertyClasses.FirstOrDefault(pc => pc != null && p.ParameterType.IsInstanceOfType(pc)); + var index = availableObjects.FindIndex(pc => p.IsInstanceOfType(pc)); + if (index < 0) + { + if (CanBeNull(p)) + delegateArgs[i] = null; + else + throw new ArgumentException( + $"No matching object found for parameter type {p.Name}. Available objects: {string.Join(", ", availableObjects.Select(o => o.GetType().Name))}"); + } + else + { + var candidate = availableObjects[index]; + availableObjects.RemoveAt(index); + availableObjects.Add(candidate); // Re-add to the end to allow reuse if needed later + delegateArgs[i] = candidate; + } } - if (args.Length != delegateArgTypes.Count) - throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}"); - - return (_templateToString.DynamicInvoke(args) as TemplatePart)!.FirstPart; + return (_templateToString.DynamicInvoke(delegateArgs) as TemplatePart)!.FirstPart; } - + /// Parse a template string to a /// The template string to parse /// A collection of with /// properties registered to match to the public static NamingTemplate Parse(string? template, IEnumerable tagCollections) { - var namingTemplate = new NamingTemplate(tagCollections); + var listOfTagCollections = tagCollections.ToList(); + var namingTemplate = new NamingTemplate(listOfTagCollections); try { var intermediate = namingTemplate.IntermediateParse(template); var evalTree = GetExpressionTree(intermediate); - namingTemplate._templateToString = Expression.Lambda(evalTree, tagCollections.Select(tc => tc.Parameter).Append(TagCollection.CultureParameter)).Compile(); + namingTemplate._templateToString = Expression.Lambda(evalTree, listOfTagCollections.Select(tc => tc.Parameter).Append(TagCollection.CultureParameter)).Compile(); } catch (Exception ex) { @@ -73,7 +86,7 @@ public class NamingTemplate return namingTemplate; } - private NamingTemplate(IEnumerable properties) + private NamingTemplate(List properties) { _tagCollections = properties; } @@ -127,23 +140,23 @@ public class NamingTemplate { CheckAndAddLiterals(); - var lastParenth = currentNode; + var lastParent = currentNode; - while (lastParenth?.IsConditional is false) - lastParenth = lastParenth.Parent; + while (lastParent?.IsConditional is false) + lastParent = lastParent.Parent; - if (lastParenth?.Parent is null) + if (lastParent?.Parent is null) { _warnings.Add($"Missing <{closingPropertyTag.TemplateTag.TagName}-> open conditional."); break; } - else if (lastParenth.Name != closingPropertyTag.TemplateTag.TagName) + else if (lastParent.Name != closingPropertyTag.TemplateTag.TagName) { - _warnings.Add($"Missing <-{lastParenth.Name}> closing conditional."); + _warnings.Add($"Missing <-{lastParent.Name}> closing conditional."); break; } - currentNode = lastParenth.Parent; + currentNode = lastParent.Parent; templateString = templateString[exactPropertyName.Length..]; } else @@ -282,4 +295,6 @@ public class NamingTemplate return newNode.IsConditional ? newNode : currentNode; } } + + private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } diff --git a/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs b/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs new file mode 100644 index 00000000..f17a45e3 --- /dev/null +++ b/Source/_Tests/FileManager.Tests/CommonFormattersTests.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using AssertionHelper; +using FileManager.NamingTemplate; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FileManager.Tests; + +[TestClass] +public class CommonFormattersTests +{ + [TestMethod] + public void TemplateStringFormatter_UnknownTag_RemainsUnchanged() + { + // Arrange + var template = "Author: {AUTHOR}, Unknown: {UNKNOWN}, Title: {TITLE}"; + var replacements = new Dictionary> + { + ["AUTHOR"] = obj => obj.Author, + ["TITLE"] = obj => obj.Title + }; + var testObj = new TestClass { Author = "John Doe", Title = "Test Book" }; + + // Act + var result = CommonFormatters.TemplateStringFormatter(testObj, template, CultureInfo.InvariantCulture, replacements); + + // Assert + Assert.AreEqual("Author: John Doe, Unknown: {UNKNOWN}, Title: Test Book", result); + } + + [TestMethod] + public void MinutesFormatter_Boundaries_ZeroMinutes() + { + // Arrange + var templateTag = new TemplateTag { TagName = "MINUTES" }; + var value = 0; + var format = "{H}:{M}"; + + // Act + var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("0:0", result); + } + + [TestMethod] + public void MinutesFormatter_Boundaries_OneDay() + { + // Arrange + var templateTag = new TemplateTag { TagName = "MINUTES" }; + var value = 1440; // 24 hours + var format = "{D}d {H}h {M}m"; + + // Act + var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("1d 0h 0m", result); + } + + [TestMethod] + public void MinutesFormatter_Boundaries_LargeValue() + { + // Arrange + var templateTag = new TemplateTag { TagName = "MINUTES" }; + var value = 3000; // 50 hours + var format = "{D}d {H}h {M}m"; + + // Act + var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("2d 2h 0m", result); // 2 days, 2 hours + } + + [TestMethod] + public void StringFormatter_InvalidCombinedFormat_ReturnsOriginal() + { + // Arrange + var templateTag = new TemplateTag { TagName = "STRING" }; + var value = "TestString"; + var invalidFormat = "invalid format with spaces and numbers 123"; + + // Act + var result = CommonFormatters.StringFormatter(templateTag, value, invalidFormat, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("TestString", result); + } + + [TestMethod] + public void TemplateStringFormatter_InvalidCombinedFormat_HandlesGracefully() + { + // Arrange + var template = "{AUTHOR:invalid:format}, {TITLE}"; + var replacements = new Dictionary> + { + ["AUTHOR"] = obj => obj.Author, + ["TITLE"] = obj => obj.Title + }; + var testObj = new TestClass { Author = "John Doe", Title = "Test Book" }; + + // Act + var result = CommonFormatters.TemplateStringFormatter(testObj, template, CultureInfo.InvariantCulture, replacements); + + // Assert + // Since AUTHOR is IFormattable? No, it's string, so uses _StringFormatter with invalid format + Assert.AreEqual("John Doe, Test Book", result); + } + + [TestMethod] + public void StringFormatter_Uppercase() + { + // Arrange + var templateTag = new TemplateTag { TagName = "STRING" }; + var value = "test string"; + var format = "U"; + + // Act + var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("TEST STRING", result); + } + + [TestMethod] + public void StringFormatter_Lowercase() + { + // Arrange + var templateTag = new TemplateTag { TagName = "STRING" }; + var value = "TEST STRING"; + var format = "L"; + + // Act + var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("test string", result); + } + + [TestMethod] + public void StringFormatter_TitleCase() + { + // Arrange + var templateTag = new TemplateTag { TagName = "STRING" }; + var value = "test string"; + var format = "T"; + + // Act + var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("Test String", result); + } + + [TestMethod] + public void StringFormatter_TitleCaseWithLength() + { + // Arrange + var templateTag = new TemplateTag { TagName = "STRING" }; + var value = "test string longer"; + var format = "10T"; + + // Act + var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("Test Strin", result); // Title case first 10 chars + } + + [TestMethod] + public void StringFormatter_MaxLength() + { + // Arrange + var templateTag = new TemplateTag { TagName = "STRING" }; + var value = "this is a very long string"; + var format = "20"; + + // Act + var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("this is a very long ", result); // Truncated to 20 chars + } + + [TestMethod] + public void FormattableFormatter_Standard() + { + // Arrange + var templateTag = new TemplateTag { TagName = "FORMATTABLE" }; + var value = 123.45; + var format = "F2"; + + // Act + var result = CommonFormatters.FormattableFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("123.45", result); + } + + [TestMethod] + public void IntegerFormatter_WithLengthAndPadding() + { + // Arrange + var templateTag = new TemplateTag { TagName = "INTEGER" }; + var value = 42; + var format = "5"; // Zero-padded to 5 digits + + // Act + var result = CommonFormatters.IntegerFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("00042", result); + } + + [TestMethod] + public void IntegerFormatter_StandardFormat() + { + // Arrange + var templateTag = new TemplateTag { TagName = "INTEGER" }; + var value = 1234; + var format = "N0"; // Number format + + // Act + var result = CommonFormatters.IntegerFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("1,234", result); + } + + [TestMethod] + public void FloatFormatter_WithLengthAndPadding() + { + // Arrange + var templateTag = new TemplateTag { TagName = "FLOAT" }; + var value = 12.34f; + var format = "F3"; // Fixed-point with 3 decimals + + // Act + var result = CommonFormatters.FloatFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("12.340", result); + } + + [TestMethod] + public void FloatFormatter_StandardFormat() + { + // Arrange + var templateTag = new TemplateTag { TagName = "FLOAT" }; + var value = 1234.567f; + var format = "N2"; // Number format with 2 decimals + + // Act + var result = CommonFormatters.FloatFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("1,234.57", result); + } + + [TestMethod] + public void DateTimeFormatter_Standard() + { + // Arrange + var templateTag = new TemplateTag { TagName = "DATETIME" }; + var value = new DateTime(2023, 10, 15, 14, 30, 0); + var format = "yyyy-MM-dd"; + + // Act + var result = CommonFormatters.DateTimeFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("2023-10-15", result); + } + + [TestMethod] + public void LanguageShortFormatter_TrimToThreeAndUppercase() + { + // Arrange + var templateTag = new TemplateTag { TagName = "LANGUAGE" }; + var value = "english"; + var format = ""; // Assuming default or empty + + // Act + var result = CommonFormatters.LanguageShortFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("ENG", result); // First 3 chars uppercase + } + + [TestMethod] + public void LanguageShortFormatter_ShortLanguage() + { + // Arrange + var templateTag = new TemplateTag { TagName = "LANGUAGE" }; + var value = "de"; + var format = ""; + + // Act + var result = CommonFormatters.LanguageShortFormatter(templateTag, value, format, CultureInfo.InvariantCulture); + + // Assert + Assert.AreEqual("DE", result); // Uppercase, no trim needed + } + + private class TestClass + { + public string? Author { get; set; } + public string? Title { get; set; } + } +} \ No newline at end of file