This repository has been archived by the owner on Dec 8, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
f4647d9
commit 25101ee
Showing
6 changed files
with
387 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
'use strict'; | ||
import { | ||
rank, | ||
exactMatch, | ||
startsWith, | ||
containsWord, | ||
containsSubString, | ||
getTitle, | ||
getKeywords, | ||
getDescription, | ||
ruleScore, | ||
} from '../searchRanking'; | ||
|
||
describe('searchRanking', () => { | ||
describe('matchers', () => { | ||
describe('exactMatch', () => { | ||
test('handles null values', () => { | ||
expect(exactMatch(null, 'hello')).toBe(false); | ||
expect(exactMatch('hello', null)).toBe(false); | ||
}); | ||
|
||
test('handles undefined values', () => { | ||
expect(exactMatch(undefined, 'hello')).toBe(false); | ||
expect(exactMatch('hello', undefined)).toBe(false); | ||
}); | ||
|
||
test('matches exactly', () => { | ||
expect(exactMatch('hello', 'hello')).toBe(true); | ||
}); | ||
|
||
test('matches case', () => { | ||
expect(exactMatch('Hello', 'hello')).toBe(false); | ||
}); | ||
|
||
test('does not match substring', () => { | ||
expect(exactMatch('hello', 'hel')).toBe(false); | ||
}); | ||
}); | ||
|
||
describe('startsWith', () => { | ||
test('handles null values', () => { | ||
expect(startsWith(null, 'hello')).toBe(false); | ||
expect(startsWith('hello', null)).toBe(false); | ||
}); | ||
|
||
test('handles undefined values', () => { | ||
expect(startsWith(undefined, 'hello')).toBe(false); | ||
expect(startsWith('hello', undefined)).toBe(false); | ||
}); | ||
|
||
test('matches full value', () => { | ||
expect(startsWith('hello', 'hello')).toBe(true); | ||
}); | ||
|
||
test('matches start of string', () => { | ||
expect(startsWith('hello', 'hel')).toBe(true); | ||
}); | ||
|
||
test('does not match substring', () => { | ||
expect(startsWith('hello', 'ell')).toBe(false); | ||
}); | ||
}); | ||
|
||
describe('containsWord', () => { | ||
test('handles null values', () => { | ||
expect(containsWord(null, 'hello')).toBe(false); | ||
expect(containsWord('hello', null)).toBe(false); | ||
}); | ||
|
||
test('handles undefined values', () => { | ||
expect(containsWord(undefined, 'hello')).toBe(false); | ||
expect(containsWord('hello', undefined)).toBe(false); | ||
}); | ||
|
||
test('matches word with surrounding spaces', () => { | ||
expect(containsWord('hi hello world', 'hello')).toBe(true); | ||
}); | ||
|
||
test('matches start of string', () => { | ||
expect(containsWord('hello world', 'hello')).toBe(true); | ||
}); | ||
|
||
test('matches end of string', () => { | ||
expect(containsWord('hello world', 'world')).toBe(true); | ||
}); | ||
|
||
test('does not match substring', () => { | ||
expect(containsWord('hihelloworld', 'hello')).toBe(false); | ||
}); | ||
|
||
test('escapes regex characters in search string', () => { | ||
expect(containsWord('hi hello world', 'hello(/[]')).toBe(false); | ||
expect(containsWord('hi hello world', '^hello$')).toBe(false); | ||
expect(containsWord('hi hello world', '^he/llo$')).toBe(false); | ||
expect(containsWord('hi hello world', '^he/l(lo)$')).toBe(false); | ||
expect(containsWord('hi hello world', '^he[a-z]$')).toBe(false); | ||
expect(containsWord('hi hello world', '^he[0-9]$')).toBe(false); | ||
}); | ||
}); | ||
|
||
describe('containsSubString', () => { | ||
test('handles null values', () => { | ||
expect(containsSubString(null, 'hello')).toBe(false); | ||
expect(containsSubString('hello', null)).toBe(false); | ||
}); | ||
|
||
test('handles undefined values', () => { | ||
expect(containsSubString(undefined, 'hello')).toBe(false); | ||
expect(containsSubString('hello', undefined)).toBe(false); | ||
}); | ||
|
||
test('matches word with surrounding spaces', () => { | ||
expect(containsSubString('hi hello world', 'hello')).toBe(true); | ||
}); | ||
|
||
test('matches start of string', () => { | ||
expect(containsSubString('hello world', 'hello')).toBe(true); | ||
}); | ||
|
||
test('matches end of string', () => { | ||
expect(containsSubString('hello world', 'world')).toBe(true); | ||
}); | ||
|
||
test('matches substring', () => { | ||
expect(containsSubString('hihelloworld', 'hello')).toBe(true); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('getters', () => { | ||
describe('getTitle', () => { | ||
test('returns title if it exists', () => { | ||
const qs = { | ||
title: 'test', | ||
}; | ||
|
||
expect(getTitle(qs)).toEqual('test'); | ||
}); | ||
|
||
test('returns empty string if title does not exist', () => { | ||
expect(getTitle({})).toEqual(''); | ||
}); | ||
}); | ||
|
||
describe('getKeywords', () => { | ||
test('returns keywords if they exists', () => { | ||
const qs = { | ||
keywords: ['test', 'test2'], | ||
}; | ||
|
||
expect(getKeywords(qs)).toEqual(['test', 'test2']); | ||
}); | ||
|
||
test('returns empty array if keywords do not exist', () => { | ||
expect(getKeywords({})).toEqual([]); | ||
}); | ||
}); | ||
describe('getDescription', () => { | ||
test('returns description if it exists', () => { | ||
const qs = { | ||
description: 'test description', | ||
}; | ||
|
||
expect(getDescription(qs)).toEqual('test description'); | ||
}); | ||
|
||
test('returns empty string if the description does not exist', () => { | ||
expect(getDescription({})).toEqual(''); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('ranking', () => { | ||
describe('ruleScore', () => { | ||
test('returns correct score for index', () => { | ||
// current ruleset has 7 rules | ||
expect(ruleScore(0)).toBe(49); | ||
expect(ruleScore(1)).toBe(36); | ||
expect(ruleScore(2)).toBe(25); | ||
expect(ruleScore(3)).toBe(16); | ||
expect(ruleScore(4)).toBe(9); | ||
expect(ruleScore(5)).toBe(4); | ||
expect(ruleScore(6)).toBe(1); | ||
}); | ||
}); | ||
|
||
describe('rank', () => { | ||
test('empty search string', () => { | ||
const qs = { | ||
title: 'test', | ||
description: 'testestest', | ||
keywords: [], | ||
}; | ||
// this is a weird case since the empty string can match a lot of stuff | ||
// it isn't a huge problem regarding actual search ranking since we won't be applying | ||
// searches for the empty string | ||
// | ||
// matches rules | ||
// title - startsWith | ||
// title - containsWord | ||
// title - containsSubString | ||
// description - containsWord | ||
// description - containsSubString | ||
expect(rank(qs, '')).toEqual(75); | ||
}); | ||
|
||
test('handles undefined search string', () => { | ||
const qs = { | ||
title: 'test', | ||
description: 'testestest', | ||
keywords: [], | ||
}; | ||
expect(rank(qs, undefined)).toEqual(0); | ||
}); | ||
|
||
test('ranks title higher', () => { | ||
const qs = { | ||
title: 'test', | ||
description: '', | ||
keywords: [], | ||
}; | ||
const qs2 = { | ||
title: 'blah', | ||
description: 'test', | ||
keywords: [], | ||
}; | ||
expect(rank(qs, 'test')).toBeGreaterThan(rank(qs2, 'test')); | ||
}); | ||
|
||
test('ranks keywords', () => { | ||
const qs = { | ||
title: 'test', | ||
description: '', | ||
keywords: ['banana', 'watermelon'], | ||
}; | ||
expect(rank(qs, 'banana')).toBe(16); | ||
}); | ||
|
||
test('trims search term', () => { | ||
const qs = { | ||
title: 'test', | ||
description: '', | ||
keywords: ['banana', 'watermelon'], | ||
}; | ||
expect(rank(qs, ' banana ')).toBe(16); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
type QuickstartPartial = { | ||
title: string; | ||
keywords: string[]; | ||
description: string; | ||
}; | ||
|
||
type Rule = { | ||
getter: (a: QuickstartPartial) => string | string[]; | ||
matcher: (a: string, b: string) => boolean; | ||
}; | ||
|
||
const escapeForRegex = (s: string): string => | ||
s?.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&') ?? s; | ||
|
||
export const exactMatch = (s: string, searchString: string): boolean => | ||
s === searchString; | ||
|
||
export const startsWith = (s: string, searchString: string): boolean => | ||
s?.startsWith(searchString) ?? false; | ||
|
||
export const containsWord = (s: string, searchString: string): boolean => | ||
new RegExp(`\\b${escapeForRegex(searchString)}\\b`).test(s); | ||
|
||
export const containsSubString = (s: string, searchString: string): boolean => | ||
s?.includes(searchString) ?? false; | ||
|
||
export const getTitle = (quickstart: QuickstartPartial): string => | ||
quickstart?.title ?? ''; | ||
|
||
export const getKeywords = (quickstart: QuickstartPartial): string[] => | ||
quickstart?.keywords ?? []; | ||
|
||
export const getDescription = (quickstart: QuickstartPartial): string => | ||
quickstart?.description ?? ''; | ||
|
||
/** | ||
* These are the rules used to rank a quickstart based on the search string. | ||
* Each rule is weighted based on its position in the array. | ||
* Ex: ruleScore(0) = (7 - 0)^2 = 49 | ||
* Ex: ruleScore(5) = (7 - 5)^2 = 4 | ||
*/ | ||
const rules: Rule[] = [ | ||
{ getter: getTitle, matcher: exactMatch }, | ||
{ getter: getTitle, matcher: startsWith }, | ||
{ getter: getTitle, matcher: containsWord }, | ||
{ getter: getKeywords, matcher: exactMatch }, | ||
{ getter: getDescription, matcher: containsWord }, | ||
{ getter: getTitle, matcher: containsSubString }, | ||
{ getter: getDescription, matcher: containsSubString }, | ||
]; | ||
|
||
export const ruleScore = (index: number): number => | ||
Math.pow(rules.length - index, 2); | ||
|
||
type RankFuncSignature = ( | ||
quickstart: QuickstartPartial, | ||
searchString: string | ||
) => number; | ||
|
||
/** | ||
* Returns a numerical value for a quickstart based on the search string | ||
* for the purposes of ranking search results. | ||
*/ | ||
export const rank: RankFuncSignature = (quickstart, searchString) => { | ||
const total = rules.reduce((score, { getter, matcher }, index) => { | ||
const quickstartField = getter(quickstart); | ||
const lowerSearch = searchString?.toLowerCase()?.trim() ?? ''; | ||
|
||
const matches = Array.isArray(quickstartField) | ||
? quickstartField.some((val) => matcher(val.toLowerCase(), lowerSearch)) | ||
: matcher(quickstartField.toLowerCase(), searchString); | ||
|
||
if (matches) { | ||
return score + ruleScore(index); | ||
} else { | ||
return score; | ||
} | ||
}, 0); | ||
|
||
return total; | ||
}; |
Oops, something went wrong.