Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions Frontmatter.Test/StandardNamingCollisionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter.Test;

using System.Collections.Generic;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// 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.
/// </summary>
[TestClass]
public class StandardNamingCollisionTests
{
[TestMethod]
public void DefaultOptions_AuthorAndCreatorListOfDifferentType_KeepsBothValues()
{
const string input = "---\nauthor: Alice\ncreator:\n - Bob\n---\nbody\n";

Dictionary<string, object>? 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);

Check warning on line 26 in Frontmatter.Test/StandardNamingCollisionTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Frontmatter&issues=AaDj7turHGdXYMwaOKW6&open=AaDj7turHGdXYMwaOKW6&pullRequest=156
}

[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<string, object>? 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<string, object>? frontmatter = Frontmatter.ExtractFrontmatter(result);

Assert.IsNotNull(frontmatter);
Assert.AreEqual("A", frontmatter["Title"]);
Assert.AreEqual("B", frontmatter["title"]);
}

[TestMethod]
public void StandardizePropertyNames_AliasBeforeStandardName_KeepsAliasUnchanged()
{
Dictionary<string, object> result = NameStandardizer.StandardizePropertyNames(new Dictionary<string, object>
{
["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<string, object> result = NameStandardizer.StandardizePropertyNames(new Dictionary<string, object>
{
["creator"] = "Bob",
});

Assert.HasCount(1, result);
Assert.AreEqual("Bob", result["author"]);
}
}
77 changes: 33 additions & 44 deletions Frontmatter/NameStandardizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,59 +27,48 @@ internal static Dictionary<string, object> StandardizePropertyNames(Dictionary<s

// Create fuzzy matches for properties that don't match standard names
Dictionary<string, object> standardizedFrontmatter = [];
KeyValuePair<string, object>[] properties = [.. frontmatter];

foreach (KeyValuePair<string, object> 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<string, object> 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)
Expand Down
Loading