Skip to content

Commit 371923b

Browse files
committed
implement close-matches api
1 parent c6e7a83 commit 371923b

File tree

2 files changed

+180
-0
lines changed

2 files changed

+180
-0
lines changed

src/__tests__/close-matches.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
calculateLevenshteinDistance,
3+
getCloseMatchesByAttribute,
4+
} from '../close-matches'
5+
import {render} from './helpers/test-utils'
6+
7+
describe('calculateLevenshteinDistance', () => {
8+
test.each([
9+
['', '', 0],
10+
['hello', 'hello', 0],
11+
['greeting', 'greeting', 0],
12+
['react testing library', 'react testing library', 0],
13+
['hello', 'hellow', 1],
14+
['greetimg', 'greeting', 1],
15+
['submit', 'sbmit', 1],
16+
['cance', 'cancel', 1],
17+
['doug', 'dog', 1],
18+
['dogs and cats', 'dogs and cat', 1],
19+
['uncool-div', '12cool-div', 2],
20+
['dogs and cats', 'dogs, cats', 4],
21+
['greeting', 'greetings traveler', 10],
22+
['react testing library', '', 21],
23+
['react testing library', 'y', 20],
24+
['react testing library', 'ty', 19],
25+
['react testing library', 'tary', 17],
26+
['react testing library', 'trary', 16],
27+
['react testing library', 'tlibrary', 13],
28+
['react testing library', 'react testing', 8],
29+
['library', 'testing', 7],
30+
['react library', 'react testing', 7],
31+
[
32+
'The more your tests resemble the way your software is used, the more confidence they can give you.',
33+
'The less your tests resemble the way your software is used, the less confidence they can give you.',
34+
8,
35+
],
36+
])('distance between "%s" and "%s" is %i', (text1, text2, expected) => {
37+
expect(calculateLevenshteinDistance(text1, text2)).toBe(expected)
38+
})
39+
})
40+
41+
describe('getCloseMatchesByAttribute', () => {
42+
test('should return all closest matches', () => {
43+
const {container} = render(`
44+
<div data-testid="The slow brown fox jumps over the lazy dog"></div>
45+
<div data-testid="The rapid brown fox jumps over the lazy dog"></div>
46+
<div data-testid="The quick black fox jumps over the lazy dog"></div>
47+
<div data-testid="The quick brown meerkat jumps over the lazy dog"></div>
48+
<div data-testid="The quick brown fox flies over the lazy dog"></div>
49+
`)
50+
expect(
51+
getCloseMatchesByAttribute(
52+
'data-testid',
53+
container,
54+
'The quick brown fox jumps over the lazy dog',
55+
),
56+
).toEqual([
57+
'The quick black fox jumps over the lazy dog',
58+
'The quick brown fox flies over the lazy dog',
59+
])
60+
})
61+
62+
test('should ignore matches that are too distant', () => {
63+
const {container} = render(`
64+
<div data-testid="very-cool-div"></div>
65+
<div data-testid="too-diferent-to-match"></div>
66+
<div data-testid="not-even-close"></div>
67+
`)
68+
expect(
69+
getCloseMatchesByAttribute('data-testid', container, 'normal-div'),
70+
).toEqual([])
71+
})
72+
73+
test('should ignore duplicated matches', () => {
74+
const {container} = render(`
75+
<div data-testid="lazy dog"></div>
76+
<div data-testid="lazy dog"></div>
77+
<div data-testid="lazy dog"></div>
78+
<div data-testid="energetic dog"></div>
79+
`)
80+
expect(
81+
getCloseMatchesByAttribute('data-testid', container, 'happy dog'),
82+
).toEqual(['lazy dog'])
83+
})
84+
})

src/close-matches.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {makeNormalizer} from './matches'
2+
3+
const initializeDpTable = (rows, columns) => {
4+
const dp = Array(rows + 1)
5+
.fill()
6+
7+
.map(() => Array(columns + 1).fill())
8+
9+
// fill rows
10+
11+
for (let i = 0; i <= rows; i++) {
12+
dp[i][0] = i
13+
}
14+
15+
// fill columns
16+
17+
for (let i = 0; i <= columns; i++) {
18+
dp[0][i] = i
19+
}
20+
21+
return dp
22+
}
23+
24+
export const calculateLevenshteinDistance = (text1, text2) => {
25+
const dp = initializeDpTable(text1.length, text2.length)
26+
27+
for (let row = 1; row < dp.length; row++) {
28+
for (let column = 1; column < dp[row].length; column++) {
29+
if (text1[row - 1] === text2[column - 1]) {
30+
dp[row][column] = dp[row - 1][column - 1]
31+
} else {
32+
dp[row][column] =
33+
Math.min(
34+
dp[row - 1][column - 1],
35+
36+
dp[row][column - 1],
37+
38+
dp[row - 1][column],
39+
) + 1
40+
}
41+
}
42+
}
43+
return dp[text1.length][text2.length]
44+
}
45+
46+
const MAX_LEVENSHTEIN_DISTANCE = 4
47+
48+
export const getCloseMatchesByAttribute = (
49+
attribute,
50+
51+
container,
52+
53+
searchText,
54+
55+
{collapseWhitespace, trim, normalizer} = {},
56+
) => {
57+
const matchNormalizer = makeNormalizer({collapseWhitespace, trim, normalizer})
58+
const allElements = Array.from(container.querySelectorAll(`[${attribute}]`))
59+
const allNormalizedValues = new Set(
60+
allElements.map(element =>
61+
matchNormalizer(element.getAttribute(attribute) || ''),
62+
),
63+
)
64+
const iterator = allNormalizedValues.values()
65+
const lowerCaseSearch = searchText.toLowerCase()
66+
let lastClosestDistance = MAX_LEVENSHTEIN_DISTANCE
67+
let closestValues = []
68+
69+
for (let normalizedText; (normalizedText = iterator.next().value); ) {
70+
if (
71+
Math.abs(normalizedText.length - searchText.length) > lastClosestDistance
72+
) {
73+
// the distance cannot be closer than what we have already found
74+
// eslint-disable-next-line no-continue
75+
continue
76+
}
77+
78+
const distance = calculateLevenshteinDistance(
79+
normalizedText.toLowerCase(),
80+
lowerCaseSearch,
81+
)
82+
83+
if (distance > lastClosestDistance) {
84+
// eslint-disable-next-line no-continue
85+
continue
86+
}
87+
88+
if (distance < lastClosestDistance) {
89+
lastClosestDistance = distance
90+
closestValues = []
91+
}
92+
closestValues.push(normalizedText)
93+
}
94+
95+
return closestValues
96+
}

0 commit comments

Comments
 (0)