feat : add fuzzy matching for searching
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { matchesTeamSearch, sortTeamsByDps } from "./team-search";
|
||||
import { matchesTeamSearch, searchTeams, sortTeamsByDps } from "./team-search";
|
||||
|
||||
const team = {
|
||||
team: ["Vaporize Team"],
|
||||
@@ -40,3 +40,33 @@ describe("team search", () => {
|
||||
expect(sortTeamsByDps(teams, null).map((item) => item.name)).toEqual(["middle", "best", "tie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("team fuzzy fallback", () => {
|
||||
it("matches typos only in the selected category", () => {
|
||||
expect(searchTeams([team], (item) => item, "mulani", "character")).toEqual({ items: [team], fuzzy: true });
|
||||
expect(searchTeams([team], (item) => item, "mulani", "artifact").items).toEqual([]);
|
||||
expect(searchTeams([team], (item) => item, "mulani", "all").items).toEqual([team]);
|
||||
});
|
||||
|
||||
it("decides fallback across all eligible teams", () => {
|
||||
const direct = { ...team, character: ["Mulani"] };
|
||||
expect(searchTeams([team, direct], (item) => item, "mulani", "all")).toEqual({ items: [direct], fuzzy: false });
|
||||
expect(searchTeams([team], (item) => item, "", "all")).toEqual({ items: [team], fuzzy: false });
|
||||
expect(searchTeams([], (item) => item, "mulani", "all")).toEqual({ items: [], fuzzy: false });
|
||||
});
|
||||
|
||||
it("does not allow direct queries to span separate names", () => {
|
||||
const values = { ...team, character: ["Jean", "Mualani"] };
|
||||
expect(searchTeams([values], (item) => item, "Jean Mualani", "character").items).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies DPS ordering after fuzzy matching and preserves fuzzy ranking without sorting", () => {
|
||||
const teams = [
|
||||
{ ...team, character: ["Mualani"], rotationSeconds: 10, members: [{ damage: 100 }] },
|
||||
{ ...team, character: ["Muloni"], rotationSeconds: 5, members: [{ damage: 100 }] },
|
||||
];
|
||||
const matches = searchTeams(teams, (item) => item, "mulani", "character").items;
|
||||
expect(sortTeamsByDps(matches, "best")).toEqual([teams[1], teams[0]]);
|
||||
expect(sortTeamsByDps(matches, null)).toEqual(teams);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeSearch, searchItems } from "../search";
|
||||
|
||||
export type TeamSearchTarget =
|
||||
| "all"
|
||||
| "team"
|
||||
@@ -47,7 +49,7 @@ export function matchesTeamSearch(
|
||||
query: string,
|
||||
target: TeamSearchTarget,
|
||||
): boolean {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const normalizedQuery = normalizeSearch(query);
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const candidates = target === "all"
|
||||
@@ -55,6 +57,23 @@ export function matchesTeamSearch(
|
||||
: values[target];
|
||||
|
||||
return candidates.some((value) =>
|
||||
value.toLocaleLowerCase().includes(normalizedQuery),
|
||||
normalizeSearch(value).includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
export function searchTeams<T>(
|
||||
teams: readonly T[],
|
||||
values: (team: T) => TeamSearchValues,
|
||||
query: string,
|
||||
target: TeamSearchTarget,
|
||||
) {
|
||||
const candidates = (team: T) => {
|
||||
const fields = values(team);
|
||||
return target === "all" ? Object.values(fields).flat() : fields[target];
|
||||
};
|
||||
// Separate candidates retain the existing rule that a direct match must
|
||||
// occur within one name, rather than spanning two members or categories.
|
||||
const direct = teams.filter((team) => matchesTeamSearch(values(team), query, target));
|
||||
if (direct.length || !query.trim()) return { items: direct, fuzzy: false };
|
||||
return searchItems(teams, query, () => "", candidates);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { searchItems } from "./search";
|
||||
|
||||
const items = [
|
||||
{ name: "Mualani", aliases: ["Shark Surfer"], overview: "A hydro guide", key: "internal" },
|
||||
{ name: "Obsidian Codex", aliases: ["Codex"], overview: "An artifact", key: "obsidian" },
|
||||
];
|
||||
const search = (query: string) => searchItems(items, query,
|
||||
(item) => `${item.name} ${item.aliases.join(" ")} ${item.overview} ${item.key}`,
|
||||
(item) => [item.name, ...item.aliases]);
|
||||
const names = (items: string[], query: string) => searchItems(items, query, (item) => item, (item) => [item]);
|
||||
|
||||
describe("fuzzy search fallback", () => {
|
||||
it("preserves direct substring matches, case, whitespace, and empty queries", () => {
|
||||
expect(search(" MUALA ")).toEqual({ items: [items[0]], fuzzy: false });
|
||||
expect(search(" ")).toEqual({ items, fuzzy: false });
|
||||
expect(search("hydro").items).toEqual([items[0]]);
|
||||
});
|
||||
|
||||
it("does not append typo matches when another item matches directly", () => {
|
||||
expect(names(["Mualani", "Mulani Guide"], "mulani")).toEqual({ items: ["Mulani Guide"], fuzzy: false });
|
||||
});
|
||||
|
||||
it.each(["mulani", "muallani", "mualoni", "mualnai"])("finds missing, extra, substituted, or swapped characters: %s", (query) => {
|
||||
expect(search(query)).toEqual({ items: [items[0]], fuzzy: true });
|
||||
});
|
||||
|
||||
it("matches aliases and individual words in multiword names", () => {
|
||||
expect(search("surfre")).toEqual({ items: [items[0]], fuzzy: true });
|
||||
expect(search("codxe")).toEqual({ items: [items[1]], fuzzy: true });
|
||||
expect(search("obsidain codex")).toEqual({ items: [items[1]], fuzzy: true });
|
||||
});
|
||||
|
||||
it("does not fuzzy match overview prose or internal keys", () => {
|
||||
expect(search("hydor")).toEqual({ items: [], fuzzy: false });
|
||||
expect(search("interanl")).toEqual({ items: [], fuzzy: false });
|
||||
});
|
||||
|
||||
it("keeps short queries strict and enforces edit limits", () => {
|
||||
expect(names(["Jean"], "jen")).toEqual({ items: [], fuzzy: false });
|
||||
expect(names(["Mualani"], "muloni").items).toEqual([]);
|
||||
expect(names(["abcdefgh"], "abxdxfgh")).toEqual({ items: ["abcdefgh"], fuzzy: true });
|
||||
expect(names(["abcdefgh"], "abxdxygh").items).toEqual([]);
|
||||
expect(search("unrelated").items).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes composed Unicode and handles Thai code points", () => {
|
||||
expect(names(["Café"], "Cafe\u0301")).toEqual({ items: ["Café"], fuzzy: false });
|
||||
expect(names(["ตัวละคร"], "ตัวลคร")).toEqual({ items: ["ตัวละคร"], fuzzy: true });
|
||||
expect(names(["😀abcd"], "😀abdc")).toEqual({ items: ["😀abcd"], fuzzy: true });
|
||||
});
|
||||
|
||||
it("ranks by distance with stable ties and retains item identities", () => {
|
||||
expect(names(["abxdxfgh", "abcxefgh", "abcdxfgh"], "abcdefgh").items)
|
||||
.toEqual(["abcxefgh", "abcdxfgh", "abxdxfgh"]);
|
||||
expect(search("mulani").items[0]).toBe(items[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface SearchResult<T> {
|
||||
items: T[];
|
||||
fuzzy: boolean;
|
||||
}
|
||||
|
||||
export function normalizeSearch(value: string): string {
|
||||
return value.normalize("NFC").trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
// Bounded optimal-string-alignment distance, including adjacent transpositions.
|
||||
function editDistance(left: string[], right: string[], limit: number): number {
|
||||
if (Math.abs(left.length - right.length) > limit) return limit + 1;
|
||||
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
let beforePrevious = previous;
|
||||
for (let i = 1; i <= left.length; i++) {
|
||||
const current = Array<number>(right.length + 1).fill(limit + 1);
|
||||
current[0] = i;
|
||||
for (let j = Math.max(1, i - limit); j <= Math.min(right.length, i + limit); j++) {
|
||||
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1,
|
||||
previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
|
||||
if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
|
||||
current[j] = Math.min(current[j], beforePrevious[j - 2] + 1);
|
||||
}
|
||||
}
|
||||
beforePrevious = previous;
|
||||
previous = current;
|
||||
}
|
||||
return previous[right.length];
|
||||
}
|
||||
|
||||
export function searchItems<T>(
|
||||
items: readonly T[],
|
||||
query: string,
|
||||
directText: (item: T) => string,
|
||||
names: (item: T) => readonly string[],
|
||||
): SearchResult<T> {
|
||||
const normalized = normalizeSearch(query);
|
||||
if (!normalized) return { items: [...items], fuzzy: false };
|
||||
const direct = items.filter((item) => normalizeSearch(directText(item)).includes(normalized));
|
||||
if (direct.length) return { items: direct, fuzzy: false };
|
||||
const characters = Array.from(normalized);
|
||||
if (characters.length < 4) return { items: [], fuzzy: false };
|
||||
const limit = Math.min(characters.length < 8 ? 1 : 2, Math.floor(characters.length / 4));
|
||||
const matches = items.map((item, index) => {
|
||||
let distance = limit + 1;
|
||||
for (const name of names(item)) {
|
||||
const candidate = normalizeSearch(name);
|
||||
for (const part of new Set([candidate, ...candidate.split(/\s+/u)])) {
|
||||
distance = Math.min(distance, editDistance(characters, Array.from(part), limit));
|
||||
}
|
||||
}
|
||||
return { item, index, distance };
|
||||
}).filter(({ distance }) => distance <= limit)
|
||||
.sort((a, b) => a.distance - b.distance || a.index - b.index);
|
||||
return { items: matches.map(({ item }) => item), fuzzy: matches.length > 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user