202 lines
5.8 KiB
TypeScript
202 lines
5.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
matchAndRank,
|
|
type CommandCandidate,
|
|
type DirectoryCandidate,
|
|
type MatchCandidate,
|
|
type MatchResult,
|
|
type SessionCandidate,
|
|
} from "./matcher.js";
|
|
|
|
function session(
|
|
name: string,
|
|
directory?: string,
|
|
command?: string,
|
|
tool?: string,
|
|
): SessionCandidate {
|
|
return { kind: "session", fields: { name, directory, command, tool } };
|
|
}
|
|
|
|
function command(label: string): CommandCandidate {
|
|
return { kind: "command", fields: { label } };
|
|
}
|
|
|
|
function directory(path: string): DirectoryCandidate {
|
|
return { kind: "directory", fields: { path } };
|
|
}
|
|
|
|
function label(result: MatchResult): string {
|
|
switch (result.candidate.kind) {
|
|
case "session":
|
|
return `session:${result.candidate.fields.name}`;
|
|
case "command":
|
|
return `command:${result.candidate.fields.label}`;
|
|
case "directory":
|
|
return `directory:${result.candidate.fields.path}`;
|
|
}
|
|
}
|
|
|
|
function positions(result: MatchResult): number[] {
|
|
return result.ranges.flatMap(([start, end]) =>
|
|
Array.from({ length: end - start }, (_, offset) => start + offset),
|
|
);
|
|
}
|
|
|
|
function expectResults(
|
|
query: string,
|
|
candidates: MatchCandidate[],
|
|
expected: Array<[label: string, score: number, field?: string, positions?: number[]]>,
|
|
): void {
|
|
const results = matchAndRank(query, candidates);
|
|
|
|
expect(results.map((result) => [label(result), result.score])).toEqual(
|
|
expected.map(([candidateLabel, score]) => [candidateLabel, score]),
|
|
);
|
|
expect(results.map((result) => result.field)).toEqual(
|
|
expected.map(([, , field]) => field),
|
|
);
|
|
expect(results.map(positions)).toEqual(expected.map(([, , , matched]) => matched ?? []));
|
|
}
|
|
|
|
describe("matchAndRank fixtures", () => {
|
|
it("F1: an empty query keeps input order", () => {
|
|
expectResults(
|
|
"",
|
|
[
|
|
session("kill", "~/dev/api", "bash"),
|
|
session("killer-app", "~/dev/killer-app", "claude --resume", "claude"),
|
|
command("Kill selected session"),
|
|
],
|
|
[
|
|
["session:kill", 0],
|
|
["session:killer-app", 0],
|
|
["command:Kill selected session", 0],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F2: an exact session name beats a prefix and kind breaks a tie", () => {
|
|
expectResults(
|
|
"kill",
|
|
[
|
|
session("kill", "~/dev/api", "bash"),
|
|
session("killer-app", "~/dev/killer-app", "claude --resume", "claude"),
|
|
command("Kill selected session"),
|
|
],
|
|
[
|
|
["session:kill", 178, "name", [0, 1, 2, 3]],
|
|
["session:killer-app", 146, "name", [0, 1, 2, 3]],
|
|
["command:Kill selected session", 146, "label", [0, 1, 2, 3]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F3: path segment boundaries beat mid-segment matches", () => {
|
|
expectResults(
|
|
"foo",
|
|
[directory("~/dev/foobar"), directory("~/src/myfoo"), directory("~/foo")],
|
|
[
|
|
["directory:~/foo", 78, "path", [2, 3, 4]],
|
|
["directory:~/dev/foobar", 74, "path", [6, 7, 8]],
|
|
["directory:~/src/myfoo", 56, "path", [8, 9, 10]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F4: a query matching nothing returns no candidates", () => {
|
|
expectResults("zzz", [session("kill", "~/dev/api", "bash"), command("Kill selected session")], []);
|
|
});
|
|
|
|
it("F5: the highest-scoring embedding can differ from the greedy embedding", () => {
|
|
expectResults(
|
|
"mtu",
|
|
[session("maestro-tui"), session("maestro-daemon"), session("mtui-scratch")],
|
|
[
|
|
["session:mtui-scratch", 122, "name", [0, 1, 2]],
|
|
["session:maestro-tui", 91, "name", [0, 8, 9]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F6: a name beats a weighted directory match", () => {
|
|
expectResults(
|
|
"api",
|
|
[session("api", "~/dev/web", "bash"), session("worker", "~/dev/api", "bash")],
|
|
[
|
|
["session:api", 154, "name", [0, 1, 2]],
|
|
["session:worker", 50, "directory", [6, 7, 8]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F7: matching is case-insensitive and field length breaks a tie", () => {
|
|
expectResults(
|
|
"api",
|
|
[session("API-Gateway"), session("apiary")],
|
|
[
|
|
["session:apiary", 122, "name", [0, 1, 2]],
|
|
["session:API-Gateway", 122, "name", [0, 1, 2]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F8: the exact tool field beats the command field", () => {
|
|
expectResults(
|
|
"claude",
|
|
[session("nameless", "~/dev/x", "claude --resume", "claude"), session("clay", "~/dev/y", "bash")],
|
|
[["session:nameless", 186, "tool", [0, 1, 2, 3, 4, 5]]],
|
|
);
|
|
});
|
|
|
|
it("F9: a separator earns a word-boundary bonus", () => {
|
|
expectResults(
|
|
"ws",
|
|
[session("websearch"), session("web-search")],
|
|
[
|
|
["session:web-search", 71, "name", [0, 4]],
|
|
["session:websearch", 56, "name", [0, 3]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F10: command labels rank by boundary and initial gap", () => {
|
|
expectResults(
|
|
"se",
|
|
[command("Kill selected session"), command("Create a session"), command("Show help")],
|
|
[
|
|
["command:Show help", 53, "label", [0, 6]],
|
|
["command:Kill selected session", 51, "label", [5, 6]],
|
|
["command:Create a session", 47, "label", [9, 10]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F11: directory matches separate on their initial gap", () => {
|
|
expectResults(
|
|
"dev",
|
|
[
|
|
session("api", "~/dev/api", "bash"),
|
|
session("notes", "~/documents/dev-notes", "nvim"),
|
|
session("scratch", "~/srv/devops", "bash"),
|
|
],
|
|
[
|
|
["session:api", 54, "directory", [2, 3, 4]],
|
|
["session:scratch", 50, "directory", [6, 7, 8]],
|
|
["session:notes", 44, "directory", [12, 13, 14]],
|
|
],
|
|
);
|
|
});
|
|
|
|
it("F12: an exact field beats a prefix", () => {
|
|
expectResults(
|
|
"quit",
|
|
[command("Quit Maestro"), command("Quit")],
|
|
[
|
|
["command:Quit", 178, "label", [0, 1, 2, 3]],
|
|
["command:Quit Maestro", 146, "label", [0, 1, 2, 3]],
|
|
],
|
|
);
|
|
});
|
|
});
|