1/**
2 * Self-check for the palette scorer. Run with:
3 * node_modules/.bin/tsx src/components/command-palette/fuzzyScore.check.ts
4 * (tsx lives in the repo root node_modules; no test framework needed.)
5 */
6import assert from "node:assert/strict";
7import { fuzzyScore, scoreCommand } from "./fuzzyScore";
8import {
9 CONTEXT_WEIGHT,
10 FLAT_OPTION_WEIGHT,
11 titleRuns,
12 type Command,
13} from "./paletteTypes";
14
15const NO_MATCH = -1;
16
17// Ranking order is the whole point: prefix > word-boundary > mid-word > scattered.
18assert.ok(
19 fuzzyScore("Change status", "change") > fuzzyScore("Change status", "status"),
20 "prefix must outrank a later word"
21);
22assert.ok(
23 fuzzyScore("Change status", "status") > fuzzyScore("Assign to", "sgn"),
24 "substring must outrank a scattered subsequence"
25);
26assert.equal(fuzzyScore("Change status", "zzz"), NO_MATCH);
27assert.equal(fuzzyScore("Change status", ""), 0, "empty query is neutral");
28
29// A missing letter must fail even when every other letter is present in order.
30assert.equal(fuzzyScore("Reply to ticket", "replyx"), NO_MATCH);
31
32// Subsequence matching still finds acronym-ish input.
33assert.ok(fuzzyScore("Change priority", "cp") > NO_MATCH);
34
35// Word-boundary bonus: "status" after a space beats the same distance mid-word.
36assert.ok(
37 fuzzyScore("Change status", "status") > fuzzyScore("Changestatus", "status"),
38 "word-boundary hit must beat a mid-word hit at the same offset"
39);
40
41// Guards against undefined titles reaching the scorer.
42assert.equal(fuzzyScore(undefined as unknown as string, "a"), NO_MATCH);
43
44// --- flat option rows vs their drill-down parent --------------------------
45// The whole point of the flat rows: "urgent" must SET the priority, while
46// "priority" must still lead with the picker. If these invert, Enter silently
47// does the wrong one of the two.
48
49const changePriority: Command = {
50 id: "ticket-priority",
51 title: "Change priority",
52 group: "Ticket",
53 weight: CONTEXT_WEIGHT,
54 keywords: "level",
55};
56const setUrgent: Command = {
57 id: "priority-Urgent",
58 title: "Set priority: Urgent",
59 group: "Ticket",
60 weight: FLAT_OPTION_WEIGHT,
61 keywords: "Urgent",
62 hideWhenEmpty: true,
63};
64const changeStatus: Command = {
65 id: "ticket-status",
66 title: "Change status",
67 group: "Ticket",
68 weight: CONTEXT_WEIGHT,
69 keywords: "state",
70};
71const setOpen: Command = {
72 id: "status-Open",
73 title: "Set status: Open",
74 group: "Ticket",
75 weight: FLAT_OPTION_WEIGHT,
76 keywords: "Open",
77 hideWhenEmpty: true,
78};
79
80assert.ok(
81 scoreCommand(setUrgent, "urgent") > scoreCommand(changePriority, "urgent"),
82 "typing an option name must lead with the row that sets it"
83);
84assert.ok(
85 scoreCommand(setOpen, "open") > scoreCommand(changeStatus, "open"),
86 "same for status names"
87);
88assert.ok(
89 scoreCommand(changePriority, "priority") > scoreCommand(setUrgent, "priority"),
90 "typing the field name must still lead with the picker"
91);
92assert.ok(
93 scoreCommand(changeStatus, "status") > scoreCommand(setOpen, "status"),
94 "same for status"
95);
96
97// Flat rows must clear the fixed rank server search hits carry (~950), or a
98// ticket whose subject contains "urgent" buries the row that sets it.
99const TOP_SEARCH_RANK = 950;
100assert.ok(
101 scoreCommand(setUrgent, "urgent") > TOP_SEARCH_RANK,
102 "flat option rows must outrank server search hits"
103);
104
105// Hidden flat rows need a deliberate (substring-or-better) match: "sett" is a
106// scattered subsequence of "Set status: …"/"Set priority: …", so it would
107// un-hide every option row at once and bury Settings' exact prefix match.
108assert.equal(scoreCommand(setUrgent, "sett"), NO_MATCH);
109assert.equal(scoreCommand(setOpen, "sett"), NO_MATCH);
110assert.ok(
111 scoreCommand(setOpen, "set status") > NO_MATCH,
112 "a deliberate prefix still surfaces the flat rows"
113);
114assert.ok(
115 scoreCommand(changeStatus, "chst") > NO_MATCH,
116 "normal rows keep scattered abbreviation matching"
117);
118
119// An empty query scores 0 everywhere, which is exactly why hideWhenEmpty exists
120// rather than a low weight — weight cannot separate rows that all tie at 0.
121assert.equal(scoreCommand(setUrgent, ""), 0);
122assert.equal(scoreCommand(changePriority, ""), 0);
123
124// Fixed ranks bypass the scorer entirely.
125assert.equal(
126 scoreCommand({ ...setUrgent, rank: 42 }, "nomatchwhatsoever"),
127 42,
128 "rank must win over the fuzzy path"
129);
130
131// --- a root row must not outrank the category it belongs to ----------------
132// "Saved Replies" leads with the word both rows share, which is what keeps the
133// category on top. The rule this pins: a category title must not bury its own
134// distinguishing word mid-title, because prefix (1000) vs word-boundary (~950)
135// is a wider gap than CONTEXT_WEIGHT 1.2 vs FLAT_OPTION_WEIGHT 1.15 can close.
136
137const savedReplies: Command = {
138 id: "ticket-saved-reply",
139 title: "Saved Replies",
140 group: "Ticket",
141 weight: CONTEXT_WEIGHT,
142 keywords: "reply template canned macro snippet",
143};
144const oneSavedReply: Command = {
145 id: "saved-reply-flat-x",
146 title: "Refund policy",
147 subtitle: "Saved Reply",
148 group: "Ticket",
149 weight: FLAT_OPTION_WEIGHT,
150 hideWhenEmpty: true,
151};
152
153for (const term of ["saved", "saved repl", "reply", "canned"]) {
154 assert.ok(
155 scoreCommand(savedReplies, term) > scoreCommand(oneSavedReply, term),
156 `"${term}" must lead with the category, not with one saved reply`
157 );
158}
159
160// "reply" is not a substring of "replies", so a plural title cannot be found by
161// the singular the agent actually types — it has to be carried in keywords.
162assert.equal(
163 scoreCommand({ ...savedReplies, keywords: undefined }, "reply"),
164 -1,
165 "documents why 'reply' is in the keywords and not left to the title"
166);
167
168// ...while the reply's own words still reach it directly, which is its whole job.
169for (const term of ["refund", "polic"]) {
170 assert.ok(
171 scoreCommand(oneSavedReply, term) > scoreCommand(savedReplies, term),
172 `"${term}" must reach the reply itself`
173 );
174}
175
176// The hazard this arrangement avoids, worth pinning because it is not obvious:
177// a category row that carries its distinguishing word *mid-title* loses to a
178// root row that leads with it. Prefix (1000) vs word-boundary (~950) is a bigger
179// gap than CONTEXT_WEIGHT 1.2 vs FLAT_OPTION_WEIGHT 1.15 can close.
180const midTitleCategory: Command = { ...savedReplies, title: "Insert saved reply" };
181const titlePrefixedRow: Command = {
182 ...oneSavedReply,
183 title: "Saved Reply: Refund policy",
184};
185assert.ok(
186 scoreCommand(titlePrefixedRow, "saved") > scoreCommand(midTitleCategory, "saved"),
187 "a mid-title category is buried by a root row that leads with the same word"
188);
189
190// Leading with that word is what makes the category safe — both score a prefix
191// match, so the weights decide and the category wins. Note this means the
192// subtitle is chosen for readability, not to escape the hazard above: a
193// "Saved reply: X" title would rank correctly under "Saved replies" too.
194assert.ok(
195 scoreCommand(savedReplies, "saved") > scoreCommand(titlePrefixedRow, "saved"),
196 "a category leading with the shared word outranks a root row on weight alone"
197);
198
199// --- server highlighting -------------------------------------------------
200// Lives here rather than in its own file: same shape of pure, import-free helper.
201
202assert.deepEqual(
203 titleRuns("Cannot <mark>login</mark> to portal"),
204 [
205 { text: "Cannot ", match: false },
206 { text: "login", match: true },
207 { text: " to portal", match: false },
208 ],
209 "runs alternate around the marked text"
210);
211
212assert.deepEqual(
213 titleRuns("<mark>Refund</mark> for <mark>order</mark>"),
214 [
215 { text: "Refund", match: true },
216 { text: " for ", match: false },
217 { text: "order", match: true },
218 ],
219 "several marks in one title"
220);
221
222assert.deepEqual(
223 titleRuns("Nothing highlighted"),
224 [{ text: "Nothing highlighted", match: false }],
225 "an unhighlighted title is one plain run"
226);
227
228// Empty runs would render as stray nodes between adjacent marks.
229assert.deepEqual(
230 titleRuns("<mark>a</mark><mark>b</mark>"),
231 [
232 { text: "a", match: true },
233 { text: "b", match: true },
234 ],
235 "adjacent marks leave no empty run between them"
236);
237
238assert.deepEqual(titleRuns(""), [], "empty in, empty out");
239
240// The whole reason for rendering runs as text nodes: markup in ticket content
241// must come out inert, never as elements.
242assert.deepEqual(
243 titleRuns("<script>alert(1)</script> and <mark>hi</mark>"),
244 [
245 { text: "alert(1) and ", match: false },
246 { text: "hi", match: true },
247 ],
248 "other tags are stripped, not rendered"
249);
250
251console.log("fuzzyScore: all checks passed");
252