feat : add fuzzy matching for searching
CI / Verify (push) Failing after 58s
CI / Build immutable images and deploy (push) Has been skipped

This commit is contained in:
2026-09-12 16:07:16 +07:00 Unverified
parent 0ec214d3e0
commit 92b57e0ed3
15 changed files with 318 additions and 79 deletions
+31 -1
View File
@@ -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);
});
});
+21 -2
View File
@@ -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);
}