1import { describe, expect, it } from "vitest";
2import {
3 commandNav,
4 filterCommands,
5 scoreCommand,
6 type CommandItem,
7 type CommandNavKey,
8 type CommandNavState,
9} from "./command-filter.js";
10
11const ITEMS: CommandItem[] = [
12 { id: "1", title: "Create page" },
13 { id: "2", title: "Create database" },
14 { id: "3", title: "Toggle dark mode" },
15 { id: "4", title: "Share workspace" },
16 { id: "5", title: "Export page" },
17];
18
19describe("scoreCommand (fuzzy subsequence)", () => {
20 it("empty query matches everything with score 0", () => {
21 expect(scoreCommand("", "Create page")).toBe(0);
22 expect(scoreCommand(" ", "Create page")).toBe(0);
23 });
24
25 it("rejects non-subsequences", () => {
26 expect(scoreCommand("xyz", "Create page")).toBe(-1);
27 expect(scoreCommand("pagee", "Create page")).toBe(-1);
28 expect(scoreCommand("pp", "Create page")).toBe(-1);
29 });
30
31 it("case-insensitive subsequence match", () => {
32 expect(scoreCommand("crpage", "Create page")).toBeGreaterThanOrEqual(0);
33 expect(scoreCommand("CRPAGE", "Create page")).toBeGreaterThanOrEqual(0);
34 });
35
36 it("contiguous matches outrank scattered ones", () => {
37 const contiguous = scoreCommand("create", "Create page");
38 const scattered = scoreCommand("crtee", "Create page");
39 expect(scattered).toBeGreaterThanOrEqual(0);
40 expect(contiguous).toBeGreaterThan(scattered);
41 });
42
43 it("word-boundary matches get a bonus", () => {
44 const wordStart = scoreCommand("cp", "Create page");
45 const nonBoundary = scoreCommand("ct", "Create page");
46 expect(wordStart).toBeGreaterThan(nonBoundary);
47 });
48});
49
50describe("filterCommands (deterministic ranking)", () => {
51 it("empty query returns all items in original order", () => {
52 expect(filterCommands(ITEMS, "").map((r) => r.item.id)).toEqual(["1", "2", "3", "4", "5"]);
53 });
54
55 it("filters out non-matches; equal scores tiebreak by original index", () => {
56 const out = filterCommands(ITEMS, "page");
57
58
59 expect(out.map((r) => r.item.id)).toEqual(["1", "5"]);
60
61
62 expect(out[0]?.score).toBeGreaterThan(out[1]?.score ?? 0);
63 });
64
65 it("tiebreaks by ORIGINAL INDEX (deterministic)", () => {
66 const items: CommandItem[] = [
67 { id: "b", title: "Alpha beta" },
68 { id: "a", title: "Alpha beta" },
69 ];
70 const out = filterCommands(items, "alpha");
71 expect(out.map((r) => r.item.id)).toEqual(["b", "a"]);
72 });
73
74 it("is stable across repeated calls", () => {
75 expect(filterCommands(ITEMS, "a").map((r) => r.item.id)).toEqual(
76 filterCommands(ITEMS, "a").map((r) => r.item.id),
77 );
78 });
79});
80
81describe("commandNav (keyboard reducer)", () => {
82 const open3: CommandNavState = { active: 0, count: 3, open: true };
83 const key = (k: CommandNavKey) => ({ type: "key" as const, key: k });
84
85 it("ArrowDown moves and wraps", () => {
86 expect(commandNav(open3, key("ArrowDown")).active).toBe(1);
87 expect(commandNav({ ...open3, active: 2 }, key("ArrowDown")).active).toBe(0);
88 });
89
90 it("ArrowUp moves and wraps", () => {
91 expect(commandNav({ ...open3, active: 0 }, key("ArrowUp")).active).toBe(2);
92 expect(commandNav(open3, key("ArrowUp")).active).toBe(2);
93 });
94
95 it("Home/End jump", () => {
96 expect(commandNav({ ...open3, active: 1 }, key("Home")).active).toBe(0);
97 expect(commandNav({ ...open3, active: 1 }, key("End")).active).toBe(2);
98 });
99
100 it("Enter is a no-op on state (selection is a side effect)", () => {
101 expect(commandNav(open3, key("Enter"))).toEqual(open3);
102 });
103
104 it("Escape closes and resets highlight", () => {
105 expect(commandNav(open3, key("Escape"))).toEqual({ active: -1, count: 3, open: false });
106 });
107
108 it("keys are ignored while closed or empty", () => {
109 const closed = { active: -1, count: 3, open: false };
110 expect(commandNav(closed, key("ArrowDown"))).toEqual(closed);
111 const empty: CommandNavState = { active: -1, count: 0, open: true };
112 expect(commandNav(empty, key("ArrowDown"))).toEqual(empty);
113 });
114
115 it("open activates index 0; close resets", () => {
116 const closed = { active: -1, count: 3, open: false };
117 expect(commandNav(closed, { type: "open" })).toEqual({ active: 0, count: 3, open: true });
118 expect(commandNav(open3, { type: "close" })).toEqual({ active: -1, count: 3, open: false });
119 });
120
121 it("results re-bounds active when the list shrinks", () => {
122 expect(commandNav({ ...open3, active: 2 }, { type: "results", count: 1 })).toEqual({
123 active: 0,
124 count: 1,
125 open: true,
126 });
127 expect(commandNav({ ...open3, active: -1 }, { type: "results", count: 0 })).toEqual({
128 active: -1,
129 count: 0,
130 open: true,
131 });
132 });
133});
134