From c13f386b957649197872b36ac07d18850c58d13f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 23:23:57 +0000 Subject: [PATCH] Charge a losing rematch as a skipped letter [patch] A subject character that re-matched the current best letter was charged only when it scored at least as well. A losing rematch cost nothing, so repeats of a well-scoring letter were free: "aaaaaaaaaab" tied an exact "ab" and outranked "abx". Fixes ktsu-dev/FuzzySearch#89 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014jNTPLxThrQnZyafw1CZ1p --- .gitignore | 18 ++++++++++++++++++ FuzzySearch.Test/FuzzyTests.cs | 28 ++++++++++++++++++++++++++++ FuzzySearch/Fuzzy.cs | 6 ++++++ 3 files changed, 52 insertions(+) diff --git a/.gitignore b/.gitignore index dc0470a..e043c9f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,11 @@ PublishScripts/ **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ +# and except a Unity project's Packages/, which is source: Unity's package manifest and its +# resolved lock file are both meant to be committed, and a NuGet restore folder never contains +# a file by either name. +!**/[Pp]ackages/manifest.json +!**/[Pp]ackages/packages-lock.json # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files @@ -651,3 +656,16 @@ Temporary Items # ImGui.ini files imgui.ini + +# Game engine projects +# +# Godot: the import cache, and the mono/temp bin+obj a C# build writes. +.godot/ + +# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule +# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs +# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently +# breaks those references - including for a plug-in whose .dll is itself a build output. This +# negation has to come after that rule to win, and is scoped to the asset tree so the Visual +# Studio artifact stays ignored everywhere else. +!**/[Aa]ssets/**/*.meta diff --git a/FuzzySearch.Test/FuzzyTests.cs b/FuzzySearch.Test/FuzzyTests.cs index 736de31..8f86293 100644 --- a/FuzzySearch.Test/FuzzyTests.cs +++ b/FuzzySearch.Test/FuzzyTests.cs @@ -860,4 +860,32 @@ public void IntegrationTest_ScoresReflectMatchQuality() } #endregion + + #region Repeated Letter Tests + + private static int ScoreOf(string subject, string pattern) + { + Fuzzy.Contains(subject, pattern, out int score); + return score; + } + + [TestMethod] + public void Score_RepeatedFirstLetter_ScoresBelowExactMatch() + { + Assert.IsGreaterThan(ScoreOf("aab", "ab"), ScoreOf("ab", "ab"), "A repeated letter should cost something."); + } + + [TestMethod] + public void Score_ManyRepeatedLetters_ScoreBelowOneTrailingLetter() + { + Assert.IsGreaterThan(ScoreOf("aaaaaaaaaab", "ab"), ScoreOf("abx", "ab"), "Nine repeated letters should cost more than one trailing letter."); + } + + [TestMethod] + public void Score_RepeatedLettersAfterSeparatorMatch_AreCharged() + { + Assert.IsGreaterThan(ScoreOf("a_bbbbbbbbbb", "ab"), ScoreOf("a_b", "ab"), "Repeated letters after a separator match should cost something."); + } + + #endregion } diff --git a/FuzzySearch/Fuzzy.cs b/FuzzySearch/Fuzzy.cs index d0196c0..a49f60f 100644 --- a/FuzzySearch/Fuzzy.cs +++ b/FuzzySearch/Fuzzy.cs @@ -255,6 +255,12 @@ internal static int CalculateScoreCore(ReadOnlySpan subject, ReadOnlySpan< bestLetterLength = strCharLength; bestLetterScore = newScore; } + else + { + // A rematch that loses to the current best letter is skipped too, so charge it like one. + // Otherwise repeats of a well-scoring letter are free and can tie an exact match. + score += unmatchedLetterPenalty; + } prevMatched = true; }