feat(app): rank combobox options by word-boundary match quality
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".
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { compareMatchScores, scoreMatch } from "./score-match";
|
||||
|
||||
describe("scoreMatch", () => {
|
||||
it("returns tier 0 for empty query", () => {
|
||||
expect(scoreMatch("", "anything")).toEqual({ tier: 0, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 0 for exact match ignoring case", () => {
|
||||
expect(scoreMatch("pi", "pi")).toEqual({ tier: 0, offset: 0 });
|
||||
expect(scoreMatch("PI", "pi")).toEqual({ tier: 0, offset: 0 });
|
||||
expect(scoreMatch("Pi", "pI")).toEqual({ tier: 0, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 1 for whole-word match at start", () => {
|
||||
expect(scoreMatch("feat", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 1 for whole-word match in middle (word boundaries on both sides)", () => {
|
||||
expect(scoreMatch("pi", "feat/pi-direct-sdk")).toEqual({ tier: 1, offset: 5 });
|
||||
expect(scoreMatch("pi", "a b pi c d")).toEqual({ tier: 1, offset: 4 });
|
||||
});
|
||||
|
||||
it("returns tier 2 for string prefix that does not complete a word", () => {
|
||||
expect(scoreMatch("par", "party")).toEqual({ tier: 2, offset: 0 });
|
||||
});
|
||||
|
||||
it("returns tier 3 for word-boundary start that does not complete a word", () => {
|
||||
expect(scoreMatch("par", "a/party")).toEqual({ tier: 3, offset: 2 });
|
||||
});
|
||||
|
||||
it("returns tier 4 for substring inside a word", () => {
|
||||
expect(scoreMatch("art", "party")).toEqual({ tier: 4, offset: 1 });
|
||||
});
|
||||
|
||||
it("returns null when query is not found", () => {
|
||||
expect(scoreMatch("xyz", "feat/pi-direct-sdk")).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the best tier across multiple occurrences", () => {
|
||||
expect(scoreMatch("ab", "xabxab-yy")).toEqual({ tier: 4, offset: 1 });
|
||||
expect(scoreMatch("ab", "xxab x-ab-y")).toEqual({ tier: 1, offset: 7 });
|
||||
});
|
||||
|
||||
it("prefers earlier offset when tiers are equal", () => {
|
||||
expect(scoreMatch("pi", "pi-a pi-b")).toEqual({ tier: 1, offset: 0 });
|
||||
});
|
||||
|
||||
it("treats common separators as word boundaries", () => {
|
||||
expect(scoreMatch("pi", "x/pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x-pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x_pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x.pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("pi", "x:pi")).toEqual({ tier: 1, offset: 2 });
|
||||
expect(scoreMatch("202", "#202 feat")).toEqual({ tier: 1, offset: 1 });
|
||||
});
|
||||
|
||||
it("scores PR-title-shaped text realistically", () => {
|
||||
const pr202 = "#202 feat(server): replace Pi ACP with direct SDK provider";
|
||||
expect(scoreMatch("pi", pr202)?.tier).toBe(1);
|
||||
expect(scoreMatch("202", pr202)).toEqual({ tier: 1, offset: 1 });
|
||||
expect(scoreMatch("replace", pr202)?.tier).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareMatchScores", () => {
|
||||
it("sorts by tier ascending", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 10 }, { tier: 2, offset: 0 })).toBeLessThan(0);
|
||||
expect(compareMatchScores({ tier: 3, offset: 0 }, { tier: 1, offset: 99 })).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tie-breaks by offset ascending at the same tier", () => {
|
||||
expect(compareMatchScores({ tier: 1, offset: 0 }, { tier: 1, offset: 5 })).toBeLessThan(0);
|
||||
expect(compareMatchScores({ tier: 1, offset: 5 }, { tier: 1, offset: 5 })).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface MatchScore {
|
||||
tier: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
function isWordBoundaryChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) return true;
|
||||
return !/[a-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
export function scoreMatch(query: string, text: string): MatchScore | null {
|
||||
if (!query) return { tier: 0, offset: 0 };
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
if (t === q) return { tier: 0, offset: 0 };
|
||||
|
||||
let best: MatchScore | null = null;
|
||||
let pos = 0;
|
||||
while (pos <= t.length - q.length) {
|
||||
const found = t.indexOf(q, pos);
|
||||
if (found === -1) break;
|
||||
const before = found > 0 ? t[found - 1] : undefined;
|
||||
const after = t[found + q.length];
|
||||
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
|
||||
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
|
||||
let tier: number;
|
||||
if (startsAtBoundary && endsAtBoundary) {
|
||||
tier = 1;
|
||||
} else if (found === 0) {
|
||||
tier = 2;
|
||||
} else if (startsAtBoundary) {
|
||||
tier = 3;
|
||||
} else {
|
||||
tier = 4;
|
||||
}
|
||||
if (!best || tier < best.tier || (tier === best.tier && found < best.offset)) {
|
||||
best = { tier, offset: found };
|
||||
}
|
||||
pos = found + 1;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
|
||||
if (a.tier !== b.tier) return a.tier - b.tier;
|
||||
return a.offset - b.offset;
|
||||
}
|
||||
Reference in New Issue
Block a user