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
123 changes: 123 additions & 0 deletions Frontmatter.Test/SingleCharacterKeyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Frontmatter.Test;

using System.Collections.Concurrent;
using System.Reflection;

using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Regression tests for keys whose normalized form is a single character, such as
/// <c>meta_x_field</c> — a decorative prefix and suffix around one letter.
/// </summary>
/// <remarks>
/// <para>
/// A one-character fragment is contained in any candidate that happens to use that letter, so the
/// bidirectional containment tests in the standardizer and the merger admit it on an incidental
/// letter rather than a shared word. <c>meta_x_field</c> normalizes to <c>x</c>, and <c>next</c>
/// contains <c>x</c>.
/// </para>
/// <para>
/// This is the same failure <see cref="DecorationOnlyKeyTests"/> pins for the empty normalized
/// form, one character further along: the value is silently attributed to an unrelated property,
/// and in the merger's case a third property is dropped outright. These tests pin the guards.
/// </para>
/// </remarks>
[TestClass]
public class SingleCharacterKeyTests
{
[TestInitialize]
public void ClearCaches()
{
ClearCache(typeof(NameStandardizer), "PropertyNameCache");
ClearCache(typeof(PropertyMerger), "PropertyMergeCache");
}

private static void ClearCache(Type type, string fieldName)
{
FieldInfo? field = type.GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static);
if (field?.GetValue(null) is ConcurrentDictionary<string, string> cache)
{
cache.Clear();
}
}

/// <summary>
/// Each of these normalizes to one character and was renamed to an unrelated standard property:
/// <c>x</c> to <c>next</c>, <c>a</c> to <c>area</c>, <c>e</c> to <c>editor</c>, <c>s</c> to
/// <c>slug</c> and <c>t</c> to <c>toc</c>.
/// </summary>
[TestMethod]
public void StandardizePropertyNames_PreservesKeysNormalizingToOneCharacter()
{
foreach (string key in new[] { "meta_x_field", "page_a_value", "meta_e_field", "custom_s_data", "page_x_text", "page_t_data" })
{
ClearCaches();

Dictionary<string, object> result =
NameStandardizer.StandardizePropertyNames(new Dictionary<string, object> { [key] = "V" });

Assert.IsTrue(result.ContainsKey(key), $"[{key}] should be preserved, got [{string.Join(",", result.Keys)}]");
Assert.AreEqual("V", result[key]);
}
}

/// <summary>
/// The merger cross-referenced mutually — <c>meta_x_field</c> to <c>next</c> and <c>next</c> back
/// to <c>meta_x_field</c>, since containment is tested in both directions — and a third key that
/// also contained the letter was dropped entirely rather than merged.
/// </summary>
[TestMethod]
public void MergeSimilarProperties_DoesNotMergeOrDropOnASingleCharacterFragment()
{
Dictionary<string, object> frontmatter = new()
{
["meta_x_field"] = "A",
["next"] = "B",
["text"] = "C",
};

Dictionary<string, object> result =
PropertyMerger.MergeSimilarProperties(frontmatter, FrontmatterMergeStrategy.Maximum);

Assert.HasCount(3, result, $"nothing should be dropped, got [{string.Join(",", result.Keys)}]");
Assert.AreEqual("A", result["meta_x_field"]);
Assert.AreEqual("B", result["next"]);
Assert.AreEqual("C", result["text"]);
}

/// <summary>
/// The rule is about containment specifically, and it is symmetric: a one-character name is
/// unmatchable whichever side it appears on, because containment is tested in both directions.
/// </summary>
[TestMethod]
public void MayMatchByContainment_RejectsAOneCharacterNameOnEitherSide()
{
Assert.IsFalse(PropertyNameNormalizer.MayMatchByContainment("x", "next"), "short first");
Assert.IsFalse(PropertyNameNormalizer.MayMatchByContainment("next", "x"), "short second");
Assert.IsFalse(PropertyNameNormalizer.MayMatchByContainment("x", "y"), "both short");
Assert.IsFalse(PropertyNameNormalizer.MayMatchByContainment("", "next"), "empty is also below the floor");

Assert.IsTrue(PropertyNameNormalizer.MayMatchByContainment("by", "author"), "two characters is the floor");
Assert.IsTrue(PropertyNameNormalizer.MayMatchByContainment("tag", "tags"));
Assert.IsTrue(PropertyNameNormalizer.MayMatchByContainment("url", "canonical_url"));
}

/// <summary>
/// The floor is two characters rather than something larger, so a key normalizing to a
/// two-character fragment still matches by containment exactly as it did before. <c>ag</c> is
/// contained in <c>tags</c>, <c>stage</c> and <c>image</c>.
/// </summary>
[TestMethod]
public void StandardizePropertyNames_StillMatchesATwoCharacterFragment()
{
ClearCaches();

Dictionary<string, object> result =
NameStandardizer.StandardizePropertyNames(new Dictionary<string, object> { ["meta_ag_field"] = "V" });

Assert.IsFalse(result.ContainsKey("meta_ag_field"),
$"[ag] is at the floor and should still be standardized, got [{string.Join(",", result.Keys)}]");
}
}
5 changes: 5 additions & 0 deletions Frontmatter/NameStandardizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ internal static Dictionary<string, object> StandardizePropertyNames(Dictionary<s
foreach (string standardProperty in standardProperties)
{
string normalizedStandard = NormalizePropertyName(standardProperty);
if (!PropertyNameNormalizer.MayMatchByContainment(normalizedKey, normalizedStandard))
{
continue;
}

if (!normalizedKey.Contains(normalizedStandard) && !normalizedStandard.Contains(normalizedKey))
{
continue;
Expand Down
8 changes: 7 additions & 1 deletion Frontmatter/PropertyMerger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@

return !isKnownMapping && !hasExactMatch && !isInSameCategory
? key
: PropertyMappings.All.TryGetValue(canonicalName, out string? knownName) ? knownName : canonicalName;

Check warning on line 106 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 106 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 106 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 106 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 106 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 106 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
}

private static bool IsInSameCategory(string key, string canonicalName)
Expand Down Expand Up @@ -195,7 +195,7 @@
/// <param name="key">The key to analyze.</param>
/// <param name="existingKeys">All existing keys in the frontmatter.</param>
/// <returns>The canonical name for the key.</returns>
private static string FindBasicCanonicalName(string key, string[] existingKeys)

Check warning on line 198 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.

Check warning on line 198 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.

Check warning on line 198 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.

Check warning on line 198 in Frontmatter/PropertyMerger.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.
{
// First check if it's a known property
if (PropertyMappings.All.TryGetValue(key, out string? canonicalName))
Expand Down Expand Up @@ -241,6 +241,11 @@
}

string normalizedExisting2 = NormalizePropertyName(existingKey);
if (!PropertyNameNormalizer.MayMatchByContainment(normalizedKey, normalizedExisting2))
{
continue;
}

if (normalizedKey.Contains(normalizedExisting2, StringComparison.OrdinalIgnoreCase) ||
normalizedExisting2.Contains(normalizedKey, StringComparison.OrdinalIgnoreCase))
{
Expand Down Expand Up @@ -300,7 +305,8 @@
{
score += 2;
}
else if (word1.Contains(word2) || word2.Contains(word1))
else if (PropertyNameNormalizer.MayMatchByContainment(word1, word2) &&
(word1.Contains(word2) || word2.Contains(word1)))
{
score += 1;
}
Expand Down
35 changes: 35 additions & 0 deletions Frontmatter/PropertyNameNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,41 @@ internal static string Normalize(string key)
return string.Join("_", key.Split(Separators, StringSplitOptions.RemoveEmptyEntries));
}

/// <summary>
/// The shortest normalized name a containment test is allowed to match on.
/// </summary>
/// <remarks>
/// Two is the smallest value that changes nothing legitimate: the shortest names anywhere in
/// <see cref="StandardOrder.PropertyNames"/> or <see cref="PropertyMappings"/> are <c>by</c>,
/// <c>tag</c> and <c>url</c>, and no standard property or mapping name normalizes to fewer than
/// two characters, so nothing real is matched by containment on a single character.
/// </remarks>
private const int MinimumMatchableLength = 2;

/// <summary>
/// Whether two normalized names carry enough content to be compared by containment.
/// </summary>
/// <param name="first">One normalized name.</param>
/// <param name="second">The other normalized name.</param>
/// <returns><see langword="true"/> when a containment match between the two would be meaningful.</returns>
/// <remarks>
/// <para>
/// Containment is tested in both directions, so the short side is what makes a match meaningless
/// regardless of which argument it is. A one-character fragment is contained in every candidate
/// that happens to use that letter, so it is admitted on an incidental letter rather than on a
/// shared word: <c>meta_x_field</c> normalizes to <c>x</c>, and <c>next</c> contains <c>x</c>.
/// </para>
/// <para>
/// This is the same failure the empty-string guards in <see cref="NameStandardizer"/> and
/// <see cref="PropertyMerger"/> already document, one character further along — a value silently
/// attributed to an unrelated property, and in the merger's case dropped outright. It applies to
/// containment only: exact matching on a one-character normalized form stays available, so a
/// genuine single-character key still pairs with another key that normalizes to the same thing.
/// </para>
/// </remarks>
internal static bool MayMatchByContainment(string first, string second) =>
first.Length >= MinimumMatchableLength && second.Length >= MinimumMatchableLength;

/// <summary>
/// Normalizes a property name and splits it into its constituent words.
/// </summary>
Expand Down
Loading