Introduce a shared scoreMatch utility that tiers matches by word-boundary vs mid-word offset, and use it to rank combobox options so e.g. "py" prefers "a/py" over "happy".
153 lines
5.2 KiB
TypeScript
153 lines
5.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
buildVisibleComboboxOptions,
|
|
filterAndRankComboboxOptions,
|
|
getComboboxFallbackIndex,
|
|
orderVisibleComboboxOptions,
|
|
} from "./combobox-options";
|
|
|
|
describe("buildVisibleComboboxOptions", () => {
|
|
const options = [
|
|
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
|
|
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
|
|
];
|
|
|
|
it("keeps a custom row visible while searching with no matches", () => {
|
|
const visible = buildVisibleComboboxOptions({
|
|
options,
|
|
searchQuery: "/tmp/new-project",
|
|
searchable: true,
|
|
allowCustomValue: true,
|
|
customValuePrefix: "",
|
|
customValueKind: "directory",
|
|
});
|
|
|
|
expect(visible).toHaveLength(1);
|
|
expect(visible[0]).toEqual({
|
|
id: "/tmp/new-project",
|
|
label: "/tmp/new-project",
|
|
description: undefined,
|
|
kind: "directory",
|
|
});
|
|
});
|
|
|
|
it("does not duplicate a row when search exactly matches an existing option", () => {
|
|
const visible = buildVisibleComboboxOptions({
|
|
options,
|
|
searchQuery: "/Users/me/project-a",
|
|
searchable: true,
|
|
allowCustomValue: true,
|
|
customValuePrefix: "",
|
|
customValueKind: "directory",
|
|
});
|
|
|
|
expect(visible).toEqual([
|
|
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("filterAndRankComboboxOptions", () => {
|
|
const options = [
|
|
{ id: "feat/login", label: "feat/login" },
|
|
{ id: "main", label: "main" },
|
|
{ id: "feat/main-nav", label: "feat/main-nav" },
|
|
{ id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
|
|
];
|
|
|
|
it("returns all options when search is empty", () => {
|
|
expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
|
|
});
|
|
|
|
it("filters by label substring", () => {
|
|
const result = filterAndRankComboboxOptions(options, "login");
|
|
expect(result.map((o) => o.id)).toEqual(["feat/login"]);
|
|
});
|
|
|
|
it("filters by id substring", () => {
|
|
const result = filterAndRankComboboxOptions(options, "fix/");
|
|
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
|
});
|
|
|
|
it("filters by description substring", () => {
|
|
const result = filterAndRankComboboxOptions(options, "logout bug");
|
|
expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
|
|
});
|
|
|
|
it("ranks prefix matches above substring matches", () => {
|
|
const result = filterAndRankComboboxOptions(options, "main");
|
|
expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
|
|
});
|
|
|
|
it("is case-insensitive", () => {
|
|
const items = [{ id: "Alpha", label: "Alpha" }];
|
|
expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
|
|
});
|
|
|
|
it("returns empty when nothing matches", () => {
|
|
expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
|
|
});
|
|
|
|
it("ranks word-boundary matches above mid-word substring matches", () => {
|
|
const items = [
|
|
{ id: "happy", label: "happy" },
|
|
{ id: "a/py", label: "a/py" },
|
|
];
|
|
const result = filterAndRankComboboxOptions(items, "py");
|
|
expect(result.map((o) => o.id)).toEqual(["a/py", "happy"]);
|
|
});
|
|
|
|
it("interleaves mixed branches and PRs by match quality", () => {
|
|
const items = [
|
|
{ id: "branch:feat/api-login", label: "feat/api-login" },
|
|
{ id: "branch:feat/pi-direct-sdk", label: "feat/pi-direct-sdk" },
|
|
{ id: "github-pr:202", label: "#202 feat(server): replace Pi ACP with direct SDK provider" },
|
|
{ id: "github-pr:355", label: "#355 feat: add LaTeX math formula rendering" },
|
|
];
|
|
const result = filterAndRankComboboxOptions(items, "pi");
|
|
expect(result.map((o) => o.id)).toEqual([
|
|
"branch:feat/pi-direct-sdk",
|
|
"github-pr:202",
|
|
"branch:feat/api-login",
|
|
]);
|
|
});
|
|
|
|
it("ranks PR-number matches via word-boundary on #", () => {
|
|
const items = [
|
|
{ id: "github-pr:202", label: "#202 some title" },
|
|
{ id: "github-pr:1202", label: "#1202 another title" },
|
|
];
|
|
const result = filterAndRankComboboxOptions(items, "202");
|
|
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
|
|
});
|
|
});
|
|
|
|
describe("combobox above-search ordering", () => {
|
|
const visible = [
|
|
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
|
|
{ id: "/Users/me/project-a", label: "/Users/me/project-a", kind: "directory" as const },
|
|
{ id: "/Users/me/project-b", label: "/Users/me/project-b", kind: "directory" as const },
|
|
];
|
|
|
|
it("renders first logical option closest to the search box in above-search mode", () => {
|
|
const ordered = orderVisibleComboboxOptions(visible, "above-search");
|
|
expect(ordered.map((option) => option.id)).toEqual([
|
|
"/Users/me/project-b",
|
|
"/Users/me/project-a",
|
|
"/tmp/new-project",
|
|
]);
|
|
expect(getComboboxFallbackIndex(ordered.length, "above-search")).toBe(2);
|
|
});
|
|
|
|
it("keeps normal top-down order in below-search mode", () => {
|
|
const ordered = orderVisibleComboboxOptions(visible, "below-search");
|
|
expect(ordered.map((option) => option.id)).toEqual([
|
|
"/tmp/new-project",
|
|
"/Users/me/project-a",
|
|
"/Users/me/project-b",
|
|
]);
|
|
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
|
|
});
|
|
});
|