diff --git a/Frontmatter.Test/StandardNamingCollisionTests.cs b/Frontmatter.Test/StandardNamingCollisionTests.cs new file mode 100644 index 0000000..fddf5a7 --- /dev/null +++ b/Frontmatter.Test/StandardNamingCollisionTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using System.Collections.Generic; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Regression tests for #136: when two keys standardize to the same name, standard naming must keep +/// both values instead of letting one overwrite the other. +/// +[TestClass] +public class StandardNamingCollisionTests +{ + [TestMethod] + public void DefaultOptions_AuthorAndCreatorListOfDifferentType_KeepsBothValues() + { + const string input = "---\nauthor: Alice\ncreator:\n - Bob\n---\nbody\n"; + + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(Frontmatter.CombineFrontmatter(input)); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("Alice", frontmatter["author"]); + Assert.IsTrue(frontmatter.TryGetValue("creator", out object? creator), "creator's list must survive alongside author"); + CollectionAssert.AreEqual(new object[] { "Bob" }, (System.Collections.ICollection)creator); + } + + [TestMethod] + public void NoMerge_AuthorAndCreator_KeepsBothValues() + { + const string input = "---\nauthor: Alice\ncreator: Bob\n---\nbody\n"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.Standard, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None); + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(result); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("Alice", frontmatter["author"]); + Assert.AreEqual("Bob", frontmatter["creator"]); + } + + [TestMethod] + public void NoMerge_TitleCasedAndLowerCased_KeepsBothValues() + { + const string input = "---\nTitle: A\ntitle: B\n---\nbody\n"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.Standard, FrontmatterOrder.AsIs, FrontmatterMergeStrategy.None); + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter(result); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual("A", frontmatter["Title"]); + Assert.AreEqual("B", frontmatter["title"]); + } + + [TestMethod] + public void StandardizePropertyNames_AliasBeforeStandardName_KeepsAliasUnchanged() + { + Dictionary result = NameStandardizer.StandardizePropertyNames(new Dictionary + { + ["creator"] = "Bob", + ["author"] = "Alice", + }); + + Assert.HasCount(2, result); + Assert.AreEqual("Bob", result["creator"]); + Assert.AreEqual("Alice", result["author"]); + } + + [TestMethod] + public void StandardizePropertyNames_LoneAlias_IsStillRenamed() + { + Dictionary result = NameStandardizer.StandardizePropertyNames(new Dictionary + { + ["creator"] = "Bob", + }); + + Assert.HasCount(1, result); + Assert.AreEqual("Bob", result["author"]); + } +} diff --git a/Frontmatter/NameStandardizer.cs b/Frontmatter/NameStandardizer.cs index b9c69bc..903933c 100644 --- a/Frontmatter/NameStandardizer.cs +++ b/Frontmatter/NameStandardizer.cs @@ -27,59 +27,48 @@ internal static Dictionary StandardizePropertyNames(Dictionary standardizedFrontmatter = []; + KeyValuePair[] properties = [.. frontmatter]; - foreach (KeyValuePair property in frontmatter) + for (int i = 0; i < properties.Length; i++) { - // Skip if it's already a standard property name - if (Array.Exists(standardProperties, p => string.Equals(p, property.Key, StringComparison.OrdinalIgnoreCase))) - { - standardizedFrontmatter[property.Key.ToLowerInvariant()] = property.Value; - continue; - } + KeyValuePair property = properties[i]; + string standardName = GetStandardName(property.Key, standardProperties); - // Check if we've already processed this property name before - if (PropertyNameCache.TryGetValue(property.Key, out string? mappedName)) - { - // If the mapped name is the same as the original, it means we previously determined - // there's no good match, so keep the original - if (mappedName == property.Key) - { - standardizedFrontmatter[property.Key] = property.Value; - } - else - { - // Use the previously matched property name - standardizedFrontmatter[mappedName] = property.Value; - } + // Two keys can resolve to the same standard name (author + creator, Title + title). Only + // rename a key when its standard name is free: not already written, and not the exact name + // of a key still to come. Otherwise keep the original key, so neither value is lost. + bool isFree = standardName == property.Key || + (!standardizedFrontmatter.ContainsKey(standardName) && + !properties.Skip(i + 1).Any(later => later.Key == standardName)); - continue; - } + standardizedFrontmatter[isFree ? standardName : property.Key] = property.Value; + } - // Try to find a match in known property mappings - string? knownMapping = FindKnownPropertyMapping(property.Key); - if (knownMapping != null) - { - PropertyNameCache.TryAdd(property.Key, knownMapping); - standardizedFrontmatter[knownMapping] = property.Value; - continue; - } + return standardizedFrontmatter; + } - // Try to find a match by removing common prefixes and suffixes - string normalizedKey = NormalizePropertyName(property.Key); - string? standardMatch = FindStandardPropertyMatch(normalizedKey, standardProperties); - if (standardMatch != null) - { - PropertyNameCache.TryAdd(property.Key, standardMatch); - standardizedFrontmatter[standardMatch] = property.Value; - continue; - } + private static string GetStandardName(string key, string[] standardProperties) + { + // Already a standard property name, possibly in a different case + if (Array.Exists(standardProperties, p => string.Equals(p, key, StringComparison.OrdinalIgnoreCase))) + { + return key.ToLowerInvariant(); + } - // If no match found, preserve the original property name - PropertyNameCache.TryAdd(property.Key, property.Key); - standardizedFrontmatter[property.Key] = property.Value; + // Check if we've already processed this property name before + if (PropertyNameCache.TryGetValue(key, out string? mappedName)) + { + return mappedName; } - return standardizedFrontmatter; + // Try to find a match in known property mappings, then by removing common prefixes and + // suffixes. If no match is found, preserve the original property name. + string standardName = FindKnownPropertyMapping(key) + ?? FindStandardPropertyMatch(NormalizePropertyName(key), standardProperties) + ?? key; + + PropertyNameCache.TryAdd(key, standardName); + return standardName; } private static string? FindKnownPropertyMapping(string key)