1import { describe, expect, it } from "vite-plus/test";
2import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
3import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
4
5import {
6 buildKeybindingRows,
7 buildKeybindingCommandOptions,
8 buildWhenVariableOptions,
9 commandLabel,
10 groupKeybindingRows,
11 keybindingConflictLabels,
12 keybindingFromKeyboardEvent,
13 parseWhenExpressionDraft,
14 shortcutToKeybindingInput,
15 unknownWhenVariables,
16 whenAstToExpression,
17 whenNodeRemoveLabel,
18} from "./KeybindingsSettings.logic";
19
20describe("KeybindingsSettings.logic", () => {
21 it("lists composer, provider, and pull request commands with editable defaults", () => {
22 const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "");
23 for (const command of [
24 "composer.sendAlternate",
25 "composer.sendBackground",
26 "thread.steerQueuedMessage",
27 "thread.editQueuedMessage",
28 "composer.host",
29 "composer.effort",
30 "composer.mode",
31 "composer.workspace",
32 "composer.branch",
33 "composer.previousWorktree",
34 "modelPicker.previousProvider",
35 "modelPicker.nextProvider",
36 "thread.copyReference",
37 "pullRequest.copyNumber",
38 ]) {
39 expect(rows.find((row) => row.command === command)).toMatchObject({
40 source: "Default",
41 conflicts: [],
42 });
43 }
44 });
45 it("finds the editable shortcut for sending the first queued message", () => {
46 expect(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "first queued")).toContainEqual(
47 expect.objectContaining({
48 command: "thread.steerQueuedMessage",
49 key: "mod+shift+enter",
50 }),
51 );
52 });
53 it.each(["pu", "pull request", "copy link", "thread id"])(
54 "finds the copy link shortcut with %s",
55 (query) => {
56 const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, query);
57 expect(rows).toContainEqual(
58 expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }),
59 );
60 },
61 );
62 it("groups rows by command area in page order and drops empty groups", () => {
63 const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""));
64 expect(groups.map((group) => group.title)).toEqual([
65 "Navigation",
66 "Threads",
67 "Composer",
68 "Terminal",
69 "Preview & diff",
70 "Appearance",
71 "Other",
72 ]);
73 const composer = groups.find((group) => group.id === "composer");
74 expect(composer?.rows.map((row) => row.command)).toEqual(
75 expect.arrayContaining(["composer.host", "modelPicker.toggle"]),
76 );
77 expect(groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "split"))).toEqual(
78 [expect.objectContaining({ id: "terminal" })],
79 );
80 });
81 it("orders Usage bindings and command choices like the page", () => {
82 const expected = [
83 "usage.open",
84 "usage.cost",
85 "usage.tokens",
86 "usage.period.day",
87 "usage.period.week",
88 "usage.period.month",
89 "usage.period.quarter",
90 ];
91 // The order must not depend on the order of the configured bindings.
92 for (const bindings of [
93 DEFAULT_RESOLVED_KEYBINDINGS,
94 DEFAULT_RESOLVED_KEYBINDINGS.toReversed(),
95 ]) {
96 expect(buildKeybindingRows(bindings, "usage").map((row) => row.command)).toEqual(expected);
97 expect(
98 buildKeybindingCommandOptions(bindings).filter((command) => command.startsWith("usage.")),
99 ).toEqual(expected);
100 }
101 });
102
103 it("omits retired Limits bindings from settings while retaining other configured shortcuts", () => {
104 const bindings = [
105 ...DEFAULT_RESOLVED_KEYBINDINGS,
106 {
107 ...DEFAULT_RESOLVED_KEYBINDINGS.find((binding) => binding.command === "usage.tokens")!,
108 command: "usage.limits" as const,
109 },
110 ];
111 expect(buildKeybindingRows(bindings, "").some((row) => row.command === "usage.limits")).toBe(
112 false,
113 );
114 expect(buildKeybindingCommandOptions(bindings)).not.toContain("usage.limits");
115 expect(buildKeybindingRows(bindings, "").some((row) => row.command === "usage.tokens")).toBe(
116 true,
117 );
118 });
119
120 it("builds searchable rows with readable key and when values", () => {
121 const rows = buildKeybindingRows(
122 [
123 {
124 command: "terminal.toggle",
125 shortcut: {
126 key: "j",
127 modKey: true,
128 metaKey: false,
129 ctrlKey: false,
130 altKey: false,
131 shiftKey: false,
132 },
133 whenAst: {
134 type: "not",
135 node: { type: "identifier", name: "terminalFocus" },
136 },
137 },
138 ] satisfies ResolvedKeybindingsConfig,
139 "terminal",
140 );
141
142 expect(rows).toEqual([
143 expect.objectContaining({
144 command: "terminal.toggle",
145 key: "mod+j",
146 when: "!terminalFocus",
147 defaultKey: "mod+j",
148 defaultWhen: "",
149 source: "Custom",
150 }),
151 ]);
152 });
153
154 it("captures platform-specific mod shortcuts", () => {
155 expect(
156 keybindingFromKeyboardEvent(
157 { key: "K", code: "KeyK", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true },
158 "MacIntel",
159 ),
160 ).toBe("mod+shift+k");
161 expect(
162 keybindingFromKeyboardEvent(
163 { key: "K", code: "KeyK", metaKey: false, ctrlKey: true, altKey: false, shiftKey: true },
164 "Win32",
165 ),
166 ).toBe("mod+shift+k");
167 });
168
169 it.each([
170 ["k", "KeyK", "k"],
171 ["Tab", "Tab", "tab"],
172 ["F5", "F5", "f5"],
173 ])("captures %s without a modifier", (key, code, expected) => {
174 const noModifiers = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false };
175 expect(keybindingFromKeyboardEvent({ key, code, ...noModifiers }, "MacIntel")).toBe(expected);
176 });
177
178 it("waits for a key when only a modifier is pressed", () => {
179 expect(
180 keybindingFromKeyboardEvent(
181 {
182 key: "Meta",
183 code: "MetaLeft",
184 metaKey: true,
185 ctrlKey: false,
186 altKey: false,
187 shiftKey: false,
188 },
189 "MacIntel",
190 ),
191 ).toBeNull();
192 });
193
194 it.each([
195 ["@", "Digit2", "mod+shift+2"],
196 ['"', "Digit2", "mod+shift+2"],
197 ["@", "Quote", "mod+shift+'"],
198 ])("captures %s at %s by physical key", (key, code, expected) => {
199 expect(
200 keybindingFromKeyboardEvent(
201 {
202 key,
203 code,
204 metaKey: true,
205 ctrlKey: false,
206 altKey: false,
207 shiftKey: true,
208 },
209 "MacIntel",
210 ),
211 ).toBe(expected);
212 });
213
214 it("captures Latin layout keys instead of their punctuation position", () => {
215 expect(
216 keybindingFromKeyboardEvent(
217 {
218 key: "m",
219 code: "Semicolon",
220 metaKey: true,
221 ctrlKey: false,
222 altKey: false,
223 shiftKey: false,
224 },
225 "MacIntel",
226 ),
227 ).toBe("mod+m");
228 });
229
230 it("serializes shortcuts and when expressions for upserts", () => {
231 expect(
232 shortcutToKeybindingInput({
233 key: " ",
234 modKey: true,
235 metaKey: false,
236 ctrlKey: false,
237 altKey: true,
238 shiftKey: false,
239 }),
240 ).toBe("mod+alt+space");
241
242 expect(
243 whenAstToExpression({
244 type: "and",
245 left: { type: "identifier", name: "editorFocus" },
246 right: {
247 type: "not",
248 node: { type: "identifier", name: "terminalFocus" },
249 },
250 }),
251 ).toBe("editorFocus && !terminalFocus");
252
253 expect(parseWhenExpressionDraft("editorFocus && (!terminalFocus || modelPickerOpen)")).toEqual({
254 ok: true,
255 value: {
256 type: "and",
257 left: { type: "identifier", name: "editorFocus" },
258 right: {
259 type: "or",
260 left: {
261 type: "not",
262 node: { type: "identifier", name: "terminalFocus" },
263 },
264 right: { type: "identifier", name: "modelPickerOpen" },
265 },
266 },
267 });
268 expect(parseWhenExpressionDraft("editorFocus &&")).toEqual({
269 ok: false,
270 message: "Use variables with !, &&, ||, and parentheses.",
271 });
272
273 expect(parseWhenExpressionDraft("!(terminalFocus || modelPickerOpen)")).toEqual({
274 ok: true,
275 value: {
276 type: "not",
277 node: {
278 type: "or",
279 left: { type: "identifier", name: "terminalFocus" },
280 right: { type: "identifier", name: "modelPickerOpen" },
281 },
282 },
283 });
284 });
285
286 it("describes the scope of each visual expression removal", () => {
287 const condition = { type: "identifier", name: "terminalFocus" } as const;
288 const negatedCondition = { type: "not", node: condition } as const;
289 const group = { type: "and", left: condition, right: negatedCondition } as const;
290 const negatedGroup = { type: "not", node: group } as const;
291
292 expect(whenNodeRemoveLabel(group, 0)).toBe("Clear all conditions");
293 expect(whenNodeRemoveLabel(condition, 1)).toBe("Remove condition");
294 expect(whenNodeRemoveLabel(negatedCondition, 1)).toBe("Remove condition");
295 expect(whenNodeRemoveLabel(group, 1)).toBe("Remove group and its conditions");
296 expect(whenNodeRemoveLabel(negatedGroup, 1)).toBe("Remove group and its conditions");
297 });
298
299 it("formats static and project script command labels", () => {
300 expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
301 expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
302 expect(commandLabel("view.reopenClosed")).toBe("Reopen Closed Tab");
303 expect(commandLabel("preview.focusUrl")).toBe("Preview: Focus URL");
304 expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
305 });
306
307 it("builds known when variable options from defaults without frontend labels", () => {
308 const options = buildWhenVariableOptions();
309
310 expect(options).toEqual(
311 expect.arrayContaining([
312 "terminalFocus",
313 "terminalOpen",
314 "isWeb",
315 "isDesktop",
316 "modelPickerOpen",
317 "true",
318 "false",
319 ]),
320 );
321 expect(options).not.toContain("customModeActive");
322 });
323
324 it("builds command options from all static commands and resolved project bindings", () => {
325 const options = buildKeybindingCommandOptions([
326 {
327 command: "script.setup-db.run",
328 shortcut: {
329 key: "r",
330 modKey: true,
331 metaKey: false,
332 ctrlKey: false,
333 altKey: false,
334 shiftKey: false,
335 },
336 },
337 ] satisfies ResolvedKeybindingsConfig);
338
339 expect(options).toEqual(
340 expect.arrayContaining([
341 "chat.new",
342 "threadPanel.toggle",
343 "rightPanel.toggleMaximized",
344 "composer.cycleHost",
345 "thread.stop",
346 "usage.open",
347 "script.setup-db.run",
348 ]),
349 );
350 for (const command of ["thread.stop", "composer.cycleHost"]) {
351 expect(DEFAULT_RESOLVED_KEYBINDINGS.some((binding) => binding.command === command)).toBe(
352 false,
353 );
354 }
355 });
356
357 it("reports unknown when variables without rejecting parseable expressions", () => {
358 const parsed = parseWhenExpressionDraft("!terminalFocus && terminalFoc");
359
360 expect(parsed.ok).toBe(true);
361 expect(unknownWhenVariables(parsed.ok ? parsed.value : undefined)).toEqual(["terminalFoc"]);
362 });
363
364 it("marks each default shortcut for multi-binding commands as default", () => {
365 const rows = buildKeybindingRows(
366 [
367 {
368 command: "chat.new",
369 shortcut: {
370 key: "n",
371 modKey: true,
372 metaKey: false,
373 ctrlKey: false,
374 altKey: false,
375 shiftKey: false,
376 },
377 whenAst: {
378 type: "not",
379 node: { type: "identifier", name: "terminalFocus" },
380 },
381 },
382 {
383 command: "chat.new",
384 shortcut: {
385 key: "o",
386 modKey: true,
387 metaKey: false,
388 ctrlKey: false,
389 altKey: false,
390 shiftKey: true,
391 },
392 whenAst: {
393 type: "not",
394 node: { type: "identifier", name: "terminalFocus" },
395 },
396 },
397 ] satisfies ResolvedKeybindingsConfig,
398 "",
399 );
400
401 expect(rows.map((row) => row.source)).toEqual(["Default", "Default"]);
402 });
403
404 it("reports conflicting shortcuts that share an active when context", () => {
405 const rows = buildKeybindingRows(
406 [
407 {
408 command: "chat.new",
409 shortcut: {
410 key: "n",
411 modKey: true,
412 metaKey: false,
413 ctrlKey: false,
414 altKey: false,
415 shiftKey: false,
416 },
417 whenAst: {
418 type: "not",
419 node: { type: "identifier", name: "terminalFocus" },
420 },
421 },
422 {
423 command: "chat.newLocal",
424 shortcut: {
425 key: "n",
426 modKey: true,
427 metaKey: false,
428 ctrlKey: false,
429 altKey: false,
430 shiftKey: false,
431 },
432 whenAst: {
433 type: "not",
434 node: { type: "identifier", name: "terminalFocus" },
435 },
436 },
437 ] satisfies ResolvedKeybindingsConfig,
438 "",
439 );
440
441 expect(rows[0]?.conflicts).toEqual(["Chat: New Local"]);
442 expect(
443 keybindingConflictLabels(rows, {
444 rowId: rows[0]?.id ?? "",
445 key: "mod+n",
446 when: "",
447 }),
448 ).toEqual(["Chat: New Local"]);
449 });
450});