diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c3d1de..7d0ec6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Release publishing now validates the tag/version and runs formatting, lint, and tests before publishing the verified build. ### Fixed +- Calculate `similarityScore` lengths and edit distance after lowercasing so Unicode case expansion cannot produce negative scores or incorrect similarity ratios. - Prevent invalid levels from hanging `tashfeer`, `tashfeerBannedWords`, and `toOldArabicAndTashfeerBannedWords`: non-number values throw `TypeError`; negative, fractional, and non-finite numbers throw `RangeError`. Random-index generation handles empty input and caps the requested count at the word length ([#35](https://github.com/Seen-Arabic/Arabic-Services-JavaScript/issues/35), [PR #36](https://github.com/Seen-Arabic/Arabic-Services-JavaScript/pull/36)). - Attempt browser bundle uploads after a successful build even if npm publishing fails ([commit 598035a](https://github.com/Seen-Arabic/Arabic-Services-JavaScript/commit/598035a)). diff --git a/src/utils/string.util.ts b/src/utils/string.util.ts index 1c45bb8..b92ade3 100644 --- a/src/utils/string.util.ts +++ b/src/utils/string.util.ts @@ -12,13 +12,17 @@ export function setCharAt(str: string, index: number, chr: string): string { } /** - * Calculates a similarity score between two strings, potentially based on their edit distance. + * Calculates a case-insensitive similarity score using Levenshtein distance over UTF-16 code units. + * Both the distance and string lengths are measured after lowercasing. * * @param {string} s1 - The first string to compare. * @param {string} s2 - The second string to compare. * @returns {number} A similarity score representing how similar the two strings are. The score is between 0 and 1. */ export function similarityScore(s1: string, s2: string): number { + s1 = s1.toLowerCase(); + s2 = s2.toLowerCase(); + let longer = s1; let shorter = s2; // swap them if s1 is bigger than s2 @@ -45,9 +49,6 @@ export function similarityScore(s1: string, s2: string): number { * @returns {number} The edit distance between the two strings. */ function editDistance(s1: string, s2: string): number { - s1 = s1.toLowerCase(); - s2 = s2.toLowerCase(); - const costs = new Array(); for (let i = 0; i <= s1.length; i++) { let lastValue = i; diff --git a/tests/scripts.test.ts b/tests/scripts.test.ts index 811a330..bf696f6 100644 --- a/tests/scripts.test.ts +++ b/tests/scripts.test.ts @@ -286,6 +286,17 @@ describe('#removeArabicAffixes', () => { }); describe('#similarityScore', () => { + it.each([ + ['İ', 'a', 0], + ['İ', 'i', 0.5], + ['İ', '', 0], + ['İ', 'i\u0307', 1], + ['A', 'a', 1], + ])('should score %j and %j as %s after case conversion in either order', (left, right, expected) => { + expect(ArabicServices.utils.similarityScore(left, right)).toBe(expected); + expect(ArabicServices.utils.similarityScore(right, left)).toBe(expected); + }); + it('should return 1 for identical strings', () => { const s1 = 'مرحبا'; const s2 = 'مرحبا';