diff --git a/Frontmatter.Test/FuzzyRankingTests.cs b/Frontmatter.Test/FuzzyRankingTests.cs new file mode 100644 index 0000000..4ca4529 --- /dev/null +++ b/Frontmatter.Test/FuzzyRankingTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that candidate property names are chosen by similarity rather than by iteration order. +/// +/// +/// Containment admits a candidate; it does not rank one. Before these matchers scored their +/// candidates, the winner was whichever plausible name +/// happened to list first, so a more specific name lost to a shorter one that merely appeared +/// earlier. +/// +[TestClass] +public class FuzzyRankingTests +{ + private static Dictionary Standardize(string key, string value) + { + string input = $"---{Environment.NewLine}" + + $"{key}: {value}{Environment.NewLine}" + + $"---{Environment.NewLine}" + + $"Content"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.Standard); + Dictionary? extracted = Frontmatter.ExtractFrontmatter(result); + + return extracted ?? throw new AssertFailedException( + $"Standardizing '{key}' produced no frontmatter to assert against."); + } + + [TestMethod] + public void StandardizePropertyNames_CandidateSharesAPrefixWithAnEarlierProperty_PrefersTheCloserMatch() + { + // "subtitles_track" contains both "title" and "subtitle". "title" is listed first in + // StandardOrder.PropertyNames, so an unranked match returns it. + Dictionary extracted = Standardize("subtitles_track", "en.vtt"); + + Assert.IsTrue( + extracted.ContainsKey("subtitle"), + $"Expected 'subtitles_track' to standardize to 'subtitle'; got '{string.Join(", ", extracted.Keys)}'."); + Assert.AreEqual("en.vtt", extracted["subtitle"]); + } + + [TestMethod] + public void StandardizePropertyNames_CandidateContainsACompoundProperty_PrefersTheMoreSpecificMatch() + { + // "review_status_flag" contains both "status" and "review_status". "status" is listed + // first, so an unranked match returns the less specific of the two. + Dictionary extracted = Standardize("review_status_flag", "approved"); + + Assert.IsTrue( + extracted.ContainsKey("review_status"), + $"Expected 'review_status_flag' to standardize to 'review_status'; got '{string.Join(", ", extracted.Keys)}'."); + Assert.AreEqual("approved", extracted["review_status"]); + } + + [TestMethod] + public void Score_NeitherNameIsASubsequenceOfTheOther_ReportsNoScore() + { + Assert.AreEqual(int.MinValue, FuzzyRanking.Score("review_notes", "notes_reviewed")); + } + + [TestMethod] + public void Score_IsSymmetric_SoAShorterCandidateStillScores() + { + // The callers' containment gates admit a candidate whether it contains the key or is + // contained by it, so a one-directional score would leave half of them unranked. + Assert.AreEqual( + FuzzyRanking.Score("subtitle", "subtitles_track"), + FuzzyRanking.Score("subtitles_track", "subtitle")); + } + + [TestMethod] + public void Score_RanksTheCloserOfTwoContainedNamesHigher() => + Assert.IsGreaterThan( + FuzzyRanking.Score("subtitles_track", "title"), + FuzzyRanking.Score("subtitles_track", "subtitle")); +} diff --git a/Frontmatter/FuzzyRanking.cs b/Frontmatter/FuzzyRanking.cs new file mode 100644 index 0000000..d55a7e5 --- /dev/null +++ b/Frontmatter/FuzzyRanking.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter; + +using ktsu.FuzzySearch; + +/// +/// Ranks candidate property names by approximate similarity, using . +/// +/// +/// Both property-name matchers in this assembly select a candidate out of a set that a coarse +/// containment test has already admitted. Containment says only whether a candidate is plausible, +/// not how good it is, so without a score the winner is decided by iteration order -- which is why +/// subtitles_track used to standardize to title rather than subtitle. +/// +/// +/// scores a match by rewarding consecutive characters, matches after a separator and matches on a +/// camelCase boundary, and by penalizing characters the pattern never matched. That is the ranking +/// both call sites were approximating by hand. +/// +/// +internal static class FuzzyRanking +{ + /// + /// Scores how well two normalized property names match, ignoring which of them is longer. + /// + /// The first normalized property name. + /// The second normalized property name. + /// + /// The better of the two directional scores, or if neither name is a + /// subsequence of the other. + /// + /// + /// Both directions are tried because the callers' containment gates are themselves + /// bidirectional: a candidate qualifies whether it contains the key or is contained by it. + /// Scoring only one direction would return no score at all for half of the candidates that the + /// gate admitted. + /// + internal static int Score(string first, string second) + { + int best = int.MinValue; + + if (Fuzzy.Contains(first.AsSpan(), second.AsSpan(), out int forward)) + { + best = forward; + } + + if (Fuzzy.Contains(second.AsSpan(), first.AsSpan(), out int reverse) && reverse > best) + { + best = reverse; + } + + return best; + } +} diff --git a/Frontmatter/NameStandardizer.cs b/Frontmatter/NameStandardizer.cs index 777ff83..def4542 100644 --- a/Frontmatter/NameStandardizer.cs +++ b/Frontmatter/NameStandardizer.cs @@ -98,17 +98,30 @@ internal static Dictionary StandardizePropertyNames(Dictionary bestScore) { - return standardProperty; + bestProperty = standardProperty; + bestScore = score; } } - return null; + return bestProperty; } private static string NormalizePropertyName(string key)