diff --git a/.eslintrc.json b/.eslintrc.json index dd3bdc9c00..9cfa58084a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -75,6 +75,7 @@ "object-curly-newline": 0, "default-case": 0, "wc/no-self-class": 0, + "no-shadow": 0, "@typescript-eslint/camelcase": 0, "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-use-before-define": 0, @@ -82,7 +83,8 @@ "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/no-unused-vars": 0, "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/explicit-module-boundary-types": 0 + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-shadow": ["error"] }, "plugins": ["disable", "import", "lit", "prettier", "@typescript-eslint"], "processor": "disable/disable" diff --git a/src/common/string/filter/char-code.ts b/src/common/string/filter/char-code.ts new file mode 100644 index 0000000000..faa7210898 --- /dev/null +++ b/src/common/string/filter/char-code.ts @@ -0,0 +1,244 @@ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export enum CharCode { + Null = 0, + /** + * The `\b` character. + */ + Backspace = 8, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, +} diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts new file mode 100644 index 0000000000..d3471433ab --- /dev/null +++ b/src/common/string/filter/filter.ts @@ -0,0 +1,463 @@ +/* eslint-disable no-console */ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CharCode } from "./char-code"; + +const _debug = false; + +export interface Match { + start: number; + end: number; +} + +const _maxLen = 128; + +function initTable() { + const table: number[][] = []; + const row: number[] = [0]; + for (let i = 1; i <= _maxLen; i++) { + row.push(-i); + } + for (let i = 0; i <= _maxLen; i++) { + const thisRow = row.slice(0); + thisRow[0] = -i; + table.push(thisRow); + } + return table; +} + +function isSeparatorAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Underline: + case CharCode.Dash: + case CharCode.Period: + case CharCode.Space: + case CharCode.Slash: + case CharCode.Backslash: + case CharCode.SingleQuote: + case CharCode.DoubleQuote: + case CharCode.Colon: + case CharCode.DollarSign: + return true; + default: + return false; + } +} + +function isWhitespaceAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Space: + case CharCode.Tab: + return true; + default: + return false; + } +} + +function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean { + return word[pos] !== wordLow[pos]; +} + +function isPatternInWord( + patternLow: string, + patternPos: number, + patternLen: number, + wordLow: string, + wordPos: number, + wordLen: number +): boolean { + while (patternPos < patternLen && wordPos < wordLen) { + if (patternLow[patternPos] === wordLow[wordPos]) { + patternPos += 1; + } + wordPos += 1; + } + return patternPos === patternLen; // pattern must be exhausted +} + +enum Arrow { + Top = 0b1, + Diag = 0b10, + Left = 0b100, +} + +/** + * A tuple of three values. + * 0. the score + * 1. the matches encoded as bitmask (2^53) + * 2. the offset at which matching started + */ +export type FuzzyScore = [number, number, number]; + +interface FilterGlobals { + _matchesCount: number; + _topMatch2: number; + _topScore: number; + _wordStart: number; + _firstMatchCanBeWeak: boolean; + _table: number[][]; + _scores: number[][]; + _arrows: Arrow[][]; +} + +function initGlobals(): FilterGlobals { + return { + _matchesCount: 0, + _topMatch2: 0, + _topScore: 0, + _wordStart: 0, + _firstMatchCanBeWeak: false, + _table: initTable(), + _scores: initTable(), + _arrows: initTable(), + }; +} + +export function fuzzyScore( + pattern: string, + patternLow: string, + patternStart: number, + word: string, + wordLow: string, + wordStart: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + const globals = initGlobals(); + const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length; + const wordLen = word.length > _maxLen ? _maxLen : word.length; + + if ( + patternStart >= patternLen || + wordStart >= wordLen || + patternLen - patternStart > wordLen - wordStart + ) { + return undefined; + } + + // Run a simple check if the characters of pattern occur + // (in order) at all in word. If that isn't the case we + // stop because no match will be possible + if ( + !isPatternInWord( + patternLow, + patternStart, + patternLen, + wordLow, + wordStart, + wordLen + ) + ) { + return undefined; + } + + let row = 1; + let column = 1; + let patternPos = patternStart; + let wordPos = wordStart; + + let hasStrongFirstMatch = false; + + // There will be a match, fill in tables + for ( + row = 1, patternPos = patternStart; + patternPos < patternLen; + row++, patternPos++ + ) { + for ( + column = 1, wordPos = wordStart; + wordPos < wordLen; + column++, wordPos++ + ) { + const score = _doScore( + pattern, + patternLow, + patternPos, + patternStart, + word, + wordLow, + wordPos + ); + + if (patternPos === patternStart && score > 1) { + hasStrongFirstMatch = true; + } + + globals._scores[row][column] = score; + + const diag = + globals._table[row - 1][column - 1] + (score > 1 ? 1 : score); + const top = globals._table[row - 1][column] + -1; + const left = globals._table[row][column - 1] + -1; + + if (left >= top) { + // left or diag + if (left > diag) { + globals._table[row][column] = left; + globals._arrows[row][column] = Arrow.Left; + } else if (left === diag) { + globals._table[row][column] = left; + globals._arrows[row][column] = Arrow.Left || Arrow.Diag; + } else { + globals._table[row][column] = diag; + globals._arrows[row][column] = Arrow.Diag; + } + } else if (top > diag) { + globals._table[row][column] = top; + globals._arrows[row][column] = Arrow.Top; + } else if (top === diag) { + globals._table[row][column] = top; + globals._arrows[row][column] = Arrow.Top || Arrow.Diag; + } else { + globals._table[row][column] = diag; + globals._arrows[row][column] = Arrow.Diag; + } + } + } + + if (_debug) { + printTables(pattern, patternStart, word, wordStart, globals); + } + + if (!hasStrongFirstMatch && !firstMatchCanBeWeak) { + return undefined; + } + + globals._matchesCount = 0; + globals._topScore = -100; + globals._wordStart = wordStart; + globals._firstMatchCanBeWeak = firstMatchCanBeWeak; + + _findAllMatches2( + row - 1, + column - 1, + patternLen === wordLen ? 1 : 0, + 0, + false, + globals + ); + if (globals._matchesCount === 0) { + return undefined; + } + + return [globals._topScore, globals._topMatch2, wordStart]; +} + +function _doScore( + pattern: string, + patternLow: string, + patternPos: number, + patternStart: number, + word: string, + wordLow: string, + wordPos: number +) { + if (patternLow[patternPos] !== wordLow[wordPos]) { + return -1; + } + if (wordPos === patternPos - patternStart) { + // common prefix: `foobar <-> foobaz` + // ^^^^^ + if (pattern[patternPos] === word[wordPos]) { + return 7; + } + return 5; + } + + if ( + isUpperCaseAtPos(wordPos, word, wordLow) && + (wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow)) + ) { + // hitting upper-case: `foo <-> forOthers` + // ^^ ^ + if (pattern[patternPos] === word[wordPos]) { + return 7; + } + return 5; + } + + if ( + isSeparatorAtPos(wordLow, wordPos) && + (wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1)) + ) { + // hitting a separator: `. <-> foo.bar` + // ^ + return 5; + } + + if ( + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1) + ) { + // post separator: `foo <-> bar_foo` + // ^^^ + return 5; + } + return 1; +} + +function printTable( + table: number[][], + pattern: string, + patternLen: number, + word: string, + wordLen: number +): string { + function pad(s: string, n: number, _pad = " ") { + while (s.length < n) { + s = _pad + s; + } + return s; + } + let ret = ` | |${word + .split("") + .map((c) => pad(c, 3)) + .join("|")}\n`; + + for (let i = 0; i <= patternLen; i++) { + if (i === 0) { + ret += " |"; + } else { + ret += `${pattern[i - 1]}|`; + } + ret += + table[i] + .slice(0, wordLen + 1) + .map((n) => pad(n.toString(), 3)) + .join("|") + "\n"; + } + return ret; +} + +function printTables( + pattern: string, + patternStart: number, + word: string, + wordStart: number, + globals: FilterGlobals +): void { + pattern = pattern.substr(patternStart); + word = word.substr(wordStart); + console.log( + printTable(globals._table, pattern, pattern.length, word, word.length) + ); + console.log( + printTable(globals._arrows, pattern, pattern.length, word, word.length) + ); + console.log( + printTable(globals._scores, pattern, pattern.length, word, word.length) + ); +} + +function _findAllMatches2( + row: number, + column: number, + total: number, + matches: number, + lastMatched: boolean, + globals: FilterGlobals +): void { + if (globals._matchesCount >= 10 || total < -25) { + // stop when having already 10 results, or + // when a potential alignment as already 5 gaps + return; + } + + let simpleMatchCount = 0; + + while (row > 0 && column > 0) { + const score = globals._scores[row][column]; + const arrow = globals._arrows[row][column]; + + if (arrow === Arrow.Left) { + // left -> no match, skip a word character + column -= 1; + if (lastMatched) { + total -= 5; // new gap penalty + } else if (matches !== 0) { + total -= 1; // gap penalty after first match + } + lastMatched = false; + simpleMatchCount = 0; + } else if (arrow && Arrow.Diag) { + if (arrow && Arrow.Left) { + // left + _findAllMatches2( + row, + column - 1, + matches !== 0 ? total - 1 : total, // gap penalty after first match + matches, + lastMatched, + globals + ); + } + + // diag + total += score; + row -= 1; + column -= 1; + lastMatched = true; + + // match -> set a 1 at the word pos + matches += 2 ** (column + globals._wordStart); + + // count simple matches and boost a row of + // simple matches when they yield in a + // strong match. + if (score === 1) { + simpleMatchCount += 1; + + if (row === 0 && !globals._firstMatchCanBeWeak) { + // when the first match is a weak + // match we discard it + return; + } + } else { + // boost + total += 1 + simpleMatchCount * (score - 1); + simpleMatchCount = 0; + } + } else { + return; + } + } + + total -= column >= 3 ? 9 : column * 3; // late start penalty + + // dynamically keep track of the current top score + // and insert the current best score at head, the rest at tail + globals._matchesCount += 1; + if (total > globals._topScore) { + globals._topScore = total; + globals._topMatch2 = matches; + } +} + +// #endregion diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts new file mode 100644 index 0000000000..e870ffe976 --- /dev/null +++ b/src/common/string/filter/sequence-matching.ts @@ -0,0 +1,65 @@ +import { fuzzyScore } from "./filter"; + +/** + * Determine whether a sequence of letters exists in another string, + * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier") + * + * @param {string} filter - Sequence of letters to check for + * @param {string} word - Word to check for sequence + * + * @return {number} Score representing how well the word matches the filter. Return of 0 means no match. + */ + +export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { + let topScore = 0; + + for (const word of words) { + const scores = fuzzyScore( + filter, + filter.toLowerCase(), + 0, + word, + word.toLowerCase(), + 0, + true + ); + + if (!scores) { + continue; + } + + // The VS Code implementation of filter treats a score of "0" as just barely a match + // But we will typically use this matcher in a .filter(), which interprets 0 as a failure. + // By shifting all scores up by 1, we allow "0" matches, while retaining score precedence + const score = scores[0] + 1; + + if (score > topScore) { + topScore = score; + } + } + return topScore; +}; + +export interface ScorableTextItem { + score?: number; + text: string; + altText?: string; +} + +type FuzzyFilterSort = ( + filter: string, + items: T[] +) => T[]; + +export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { + return items + .map((item) => { + item.score = item.altText + ? fuzzySequentialMatch(filter, item.text, item.altText) + : fuzzySequentialMatch(filter, item.text); + return item; + }) + .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => + scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 + ); +}; diff --git a/src/common/string/sequence_matching.ts b/src/common/string/sequence_matching.ts deleted file mode 100644 index 921b2471df..0000000000 --- a/src/common/string/sequence_matching.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Determine whether a sequence of letters exists in another string, - * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier") - * - * filter => sequence of letters - * word => Word to check for sequence - * - * return true if word contains sequence. Otherwise false. - */ -export const fuzzySequentialMatch = (filter: string, words: string[]) => { - for (const word of words) { - if (_fuzzySequentialMatch(filter.toLowerCase(), word.toLowerCase())) { - return true; - } - } - return false; -}; - -const _fuzzySequentialMatch = (filter: string, word: string) => { - if (filter === "") { - return true; - } - - for (let i = 0; i <= filter.length; i++) { - const pos = word.indexOf(filter[0]); - - if (pos < 0) { - return false; - } - - const newWord = word.substring(pos + 1); - const newFilter = filter.substring(1); - - return _fuzzySequentialMatch(newFilter, newWord); - } - - return true; -}; diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index f3c0202ff4..b7c099855c 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -17,7 +17,10 @@ import "../../components/ha-dialog"; import { haStyleDialog } from "../../resources/styles"; import { HomeAssistant } from "../../types"; import { PolymerChangedEvent } from "../../polymer-types"; -import { fuzzySequentialMatch } from "../../common/string/sequence_matching"; +import { + fuzzyFilterSort, + ScorableTextItem, +} from "../../common/string/filter/sequence-matching"; import { componentsWithService } from "../../common/config/components_with_service"; import { domainIcon } from "../../common/entity/domain_icon"; import { computeDomain } from "../../common/entity/compute_domain"; @@ -27,12 +30,9 @@ import { compare } from "../../common/string/compare"; import { SingleSelectedEvent } from "@material/mwc-list/mwc-list-foundation"; import { computeStateName } from "../../common/entity/compute_state_name"; -interface QuickBarItem { - text: string; - altText?: string; +interface QuickBarItem extends ScorableTextItem { icon: string; action(data?: any): void; - score?: number; } @customElement("ha-quick-bar") @@ -184,19 +184,20 @@ export class QuickBar extends LitElement { this._itemFilter = newFilter; } - this._items = (this._commandMode ? this._commandItems : this._entityItems) - .filter(({ text, altText }) => { - const values = [text]; - if (altText) { - values.push(altText); - } - return fuzzySequentialMatch(this._itemFilter.trimLeft(), values); - }) - .sort((itemA, itemB) => compare(itemA.text, itemB.text)); + this._items = this._commandMode ? this._commandItems : this._entityItems; + + if (this._itemFilter !== "") { + this._items = fuzzyFilterSort( + this._itemFilter.trimLeft(), + this._items + ); + } } private _generateCommandItems(): QuickBarItem[] { - return [...this._generateReloadCommands()]; + return [...this._generateReloadCommands()].sort((a, b) => + compare(a.text.toLowerCase(), b.text.toLowerCase()) + ); } private _generateReloadCommands(): QuickBarItem[] { @@ -216,12 +217,14 @@ export class QuickBar extends LitElement { } private _generateEntityItems(): QuickBarItem[] { - return Object.keys(this.hass.states).map((entityId) => ({ - text: computeStateName(this.hass.states[entityId]), - altText: entityId, - icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), - action: () => fireEvent(this, "hass-more-info", { entityId }), - })); + return Object.keys(this.hass.states) + .map((entityId) => ({ + text: computeStateName(this.hass.states[entityId]), + altText: entityId, + icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), + action: () => fireEvent(this, "hass-more-info", { entityId }), + })) + .sort((a, b) => compare(a.text.toLowerCase(), b.text.toLowerCase())); } static get styles() { diff --git a/test-mocha/common/string/sequence_matching.test.ts b/test-mocha/common/string/sequence_matching.test.ts index babc35696a..8621a36274 100644 --- a/test-mocha/common/string/sequence_matching.test.ts +++ b/test-mocha/common/string/sequence_matching.test.ts @@ -1,34 +1,48 @@ import { assert } from "chai"; -import { fuzzySequentialMatch } from "../../../src/common/string/sequence_matching"; +import { + fuzzyFilterSort, + fuzzySequentialMatch, +} from "../../../src/common/string/filter/sequence-matching"; describe("fuzzySequentialMatch", () => { const entity = { entity_id: "automation.ticker", friendly_name: "Stocks" }; + const createExpectation: ( + pattern, + expected + ) => { + pattern: string; + expected: string | number | undefined; + } = (pattern, expected) => ({ + pattern, + expected, + }); + const shouldMatchEntity = [ - "", - "automation.ticker", - "automation.ticke", - "automation.", - "au", - "automationticker", - "tion.tick", - "ticker", - "automation.r", - "tick", - "aumatick", - "aion.tck", - "ioticker", - "atmto.ikr", - "uoaintce", - "au.tce", - "tomaontkr", - "s", - "stocks", - "sks", + createExpectation("automation.ticker", 138), + createExpectation("automation.ticke", 129), + createExpectation("automation.", 89), + createExpectation("au", 17), + createExpectation("automationticker", 107), + createExpectation("tion.tick", 18), + createExpectation("ticker", 1), + createExpectation("automation.r", 89), + createExpectation("tick", 1), + createExpectation("aumatick", 15), + createExpectation("aion.tck", 14), + createExpectation("ioticker", 19), + createExpectation("atmto.ikr", 1), + createExpectation("uoaintce", 1), + createExpectation("au.tce", 17), + createExpectation("tomaontkr", 9), + createExpectation("s", 7), + createExpectation("stocks", 48), + createExpectation("sks", 7), ]; const shouldNotMatchEntity = [ + "", " ", "abcdefghijklmnopqrstuvwxyz", "automation.tickerz", @@ -40,24 +54,50 @@ describe("fuzzySequentialMatch", () => { ]; describe(`Entity '${entity.entity_id}'`, () => { - for (const goodFilter of shouldMatchEntity) { - it(`matches with '${goodFilter}'`, () => { - const res = fuzzySequentialMatch(goodFilter, [ + for (const expectation of shouldMatchEntity) { + it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => { + const res = fuzzySequentialMatch( + expectation.pattern, entity.entity_id, - entity.friendly_name.toLowerCase(), - ]); - assert.equal(res, true); + entity.friendly_name + ); + assert.equal(res, expectation.expected); }); } for (const badFilter of shouldNotMatchEntity) { it(`fails to match with '${badFilter}'`, () => { - const res = fuzzySequentialMatch(badFilter, [ + const res = fuzzySequentialMatch( + badFilter, entity.entity_id, - entity.friendly_name, - ]); - assert.equal(res, false); + entity.friendly_name + ); + assert.equal(res, 0); }); } }); }); + +describe("fuzzyFilterSort", () => { + const filter = "ticker"; + const item1 = { text: "automation.ticker", altText: "Stocks", score: 0 }; + const item2 = { text: "sensor.ticker", altText: "Stocks up", score: 0 }; + const item3 = { + text: "automation.check_router", + altText: "Timer Check Router", + score: 0, + }; + const itemsBeforeFilter = [item1, item2, item3]; + + it(`sorts correctly`, () => { + const expectedItemsAfterFilter = [ + { ...item2, score: 23 }, + { ...item3, score: 12 }, + { ...item1, score: 1 }, + ]; + + const res = fuzzyFilterSort(filter, itemsBeforeFilter); + + assert.deepEqual(res, expectedItemsAfterFilter); + }); +});