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]], ], ); }); });